Skip to content

GT-603: the audit ledger can finally say whether a human or an agent did it - #81

Merged
beyondnetPeru merged 3 commits into
developfrom
feat/gt-603-agent-turn-ledger
Aug 1, 2026
Merged

GT-603: the audit ledger can finally say whether a human or an agent did it#81
beyondnetPeru merged 3 commits into
developfrom
feat/gt-603-agent-turn-ledger

Conversation

@beyondnetPeru

Copy link
Copy Markdown
Contributor

Closes GT-603 (P0). All four acceptance criteria met, each watched red before green.

Why this one was urgent in a way no other row is

audit_entries is append-only by database trigger. Every row written before the discriminator exists is permanently unattributable — this is the one item on the board that expires instead of accruing cost. The product's differentiating claim (human-versus-agent attribution) was false in code that was already 90% written: AgentExecutionService and AgentTurnAuditor existed, were tested, and appeared in zero DI registrations and zero endpoints.

The append-only trigger, probed rather than assumed

Before writing the migration, the trigger was tested against a real PostgreSQL. It forbids UPDATE, DELETE, TRUNCATE — not INSERT, and not DDL. In one transaction: ADD COLUMN actor_type varchar(32) NOT NULL DEFAULT 'unknown' succeeds (PG≥11 fast default, no rewrite, no DML trigger), while UPDATE … SET actor_type='human' is rejected:

ERROR: audit_entries es append-only (DP-5): UPDATE no esta permitido sobre el registro de auditoria

So back-filling is not merely undesirable — it is impossible. Confirmed independently by the integrating session on the live database.

Chosen shape: the DEFAULT exists only for the ADD COLUMN statement and is dropped immediately after, plus a COMMENT ON COLUMN recording the semantics. Old rows read unknown — "not declared, and untypeable" — and any new INSERT omitting the discriminator fails with 23502. AuditActorType.Writable excludes unknown and AuditEntry.Create rejects it, so no application path can produce it; Rehydrate exists so old rows stay readable. The three append-only triggers are untouched, and a live test asserts all three survive the migration.

Verified on the live DB by the integrating session: actor_type is varchar(32) NOT NULL with no default, and the rows read agent 4 · human 6 · unknown 1.

Evidence per criterion

  1. Port + auditor registered, AssistantEndpoints goes through them. RED: ...to have an item matching (d.ServiceType == IAgentExecutionPort), and Expected asientos not to be empty. GREEN: 20/20 on those classes (re-run independently), full solution 1034 passed / 0 failed plus 14/14 architecture tests. The endpoint response body is unchanged, asserted by a test.
  2. Schema. RED against real Postgres: 42703: column "actor_type" does not exist. GREEN after 20260731021901_AddAuditActorAttribution, generated by dotnet ef with the snapshot untouched by hand.
  3. Robot end to end. RED produced by restoring the pre-change AssistantEndpoints.cs and restarting the server: ✗ the agent turn is in the trail — the agent path wrote nothing, actorType=undefined, FAIL audit-trail (19 ok, 6 failed). GREEN: PASS audit-trail (25 ok, 0 failed), with actorType=agent, agentId, sessionId, scope, and the prompt itself is not stored.
  4. Migration lands before any production write — nothing is deployed, and the migration ships in the same commit as the code that writes the column.

A latent bug fixed on the way

AgentTurnAuditor called AddAsync with no SaveEntitiesAsync. "Audit before executing, abort if the audit write fails" was a claim about an in-memory change nobody committed. It now commits, and converts write failures into a Result instead of an exception. Relatedly, a turn with no agent identity is now rejected before anything is written: on an append-only ledger a mis-attributed row is permanent, so not writing beats writing wrong.

AuditEntry.Create now takes attribution as a required parameter, so all 8 write sites had to declare their type; the AOP sink translates the envelope's existing SubjectType rather than inventing one.

ADR: warranted, not written

A T-NNN should cover the closed set of actor types, the decision that pre-migration rows stay unknown forever, and the ratification that agent turns share audit_entries rather than a separate AgentActionLog — that last one currently lives only in code comments from EAG-16.

Deliberately left out

  • reference/specs/design/tracker-postgresql-data-design.md §12.1 describes audit_entries in a schema and with columns that never matched the implementation — pre-existing drift, untouched.
  • model_id is config-driven rather than harvested from the runtime response, because the row is written before execution.
  • Gateway failures now surface as a uniform 502 instead of mirroring upstream; scope/audit failures map to 400/403/503.
  • Two pre-existing failures appear only under EVOLITH_CORE_LIVE=1; they need a running Evolith Core and are unrelated.

🤖 Generated with Claude Code

beyondnetPeru and others added 3 commits July 30, 2026 21:25
…ico (GT-603)

El ledger de turnos agénticos estaba escrito, probado y SIN REGISTRAR:
`IAgentExecutionPort` no aparecía en ningún contenedor ni en ningún endpoint, y
`/assistant/converse` —la única vía por la que un agente tocaba el corpus de un
cliente— proxyaba al runtime sin persistir nada. La afirmación diferencial del
producto (distinguir un acto humano de uno agéntico en el expediente) era falsa
en el código.

Y era la ficha que CADUCA: `audit_entries` es append-only por trigger desde
COH-014, así que toda fila escrita antes de que existiera el discriminador queda
inatribuible para siempre — el UPDATE que la tiparía está prohibido por la base.

- `AuditActor`/`AuditActorType`: human | agent | system, más `unknown` reservado
  a lo anterior a la migración y EXCLUIDO de toda ruta de escritura.
- `AuditEntry.Create` exige la atribución como parámetro obligatorio: los 8
  sitios que escriben en el expediente declaran ahora quién escribe. Se añade
  `Rehydrate` para poder LEER `unknown` sin poder escribirlo.
- Migración `AddAuditActorAttribution`: `actor_type` NOT NULL + `agent_id`,
  `model_id`, `session_id`. El DEFAULT `'unknown'` vive sólo dentro del
  `ADD COLUMN` (fast default: sin reescritura, sin disparar el trigger) y se
  RETIRA acto seguido, para que ninguna fila nueva entre sin declarar su actor.
  Los tres triggers append-only quedan intactos.
- `AssistantEndpoints` pasa por el puerto: scope comprobado, turno asentado y
  sólo entonces se deja actuar al runtime. La respuesta se devuelve intacta.
- `AgentTurnAuditor` CONFIRMA la escritura (`SaveEntitiesAsync`): sin ella, «el
  turno se pudo registrar» era una afirmación sobre memoria que nadie persistía.
- Un turno sin identidad de agente se rechaza: no es atribuible, y en un registro
  que no admite corrección es preferible no escribirlo a escribirlo mal.
- `audit-trail.robot.mjs` verifica extremo a extremo, contra la API viva, que un
  turno agéntico aterriza tipado como `agent` con su agente identificado y que un
  acto humano en el MISMO expediente sigue diciendo `human`.

Verificado: 1034 pruebas + 14 de arquitectura en verde; las pruebas vivas de
esquema contra PostgreSQL real (EVOLITH_CORE_LIVE=1) y RoboSoft `audit-trail`
25/25 contra tracker-api en marcha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment never installs

`Deploy check (kind + Helm + smoke)` was the only red job on this branch, and the
only red assertion in it was `POST /assistant/converse responds 2xx` — status 502.
Every ledger assertion around it passed: the turn is in the trail, typed `agent`,
with the acting agent identified, the human principal preserved, the scope declared
and the prompt absent.

That is not a flake and not a regression. `AssistantEndpoints` seats the turn through
`IAgentExecutionPort` BEFORE the runtime is allowed to act, then proxies; the chart
wires `AgentRuntime__BaseUrl` to `evolith-agent-runtime`, a service the kind smoke
does not install, so the proxy leg returns 502 `AgentRuntime.Failed` while the
seating — the thing this scenario exists to prove — succeeds.

So the check asserted the availability of an absent dependency. It now asserts what
the deployment can actually answer for: the turn was seated. 2xx when a runtime
replies, 502 when none is deployed, and still red on 4xx (scope or attribution
refused, no turn seated) or 503 (`AgentExecution.NotAuditable` / `AgentTurnAuditor.*`
— the ledger refused the entry).

Not fixed by enabling `MockFallback`: that would turn the ledger assertions green
against a stub reply. `smoke-app.sh` pins `CoreApi__MockFallback=false` for the same
reason. Installing the runtime in the smoke environment is the real closure and
belongs with the deployment, not with this gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…za (GT-616) y ledger (GT-603)

El PR estaba DIRTY, y por eso GitHub no ejecutaba ni un check: no podía calcular
el merge. El conflicto es real, no textual. Mientras esta rama estaba abierta,
`develop` recibió GT-616 y le puso una traza de gobernanza al mismo endpoint —
pero sobre la vía ANTIGUA, `gateway.ConverseAsync`, que es exactamente la que
GT-603 sustituye por `IAgentExecutionPort`.

Quedarse con un lado perdía algo real: el de develop, el asiento en el expediente;
el de la rama, la traza. Se conservan los dos.

La traza envuelve al PUERTO, no al gateway. Desde GT-603 el turno se asienta antes
de que el runtime actúe, así que una traza que empezara después no vería el único
tramo capaz de rechazar el turno — que es el que hay que poder buscar.

Las tres salidas quedan etiquetadas: `ex.Code` en el rechazo de perímetro,
`resultado.Error` SIN traducir cuando falla el puerto, y `ok` en el éxito. El error
sin traducir es deliberado: `AgentExecution.NotAuditable` y `AgentRuntime.Failed`
salen ambos como 5xx, pero en la traza son dos historias distintas —el expediente
rechazó el asiento, o el runtime no respondió— y colapsarlas al código HTTP borraría
justo la que hay que poder distinguir.

Verificado: `Tracker.Presentation` compila con 0 avisos y 245 tests de
Assistant/AgentExecution/Tracing/Governance en verde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@beyondnetPeru
beyondnetPeru merged commit b1f80ef into develop Aug 1, 2026
4 of 5 checks passed
@beyondnetPeru
beyondnetPeru deleted the feat/gt-603-agent-turn-ledger branch August 1, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant