feat(spec,core,objectql,plugin-audit): record the acting agent on the audit row (ADR-0090 D10 rule 4 dual attribution) - #18371
Conversation
…e audit row
ADR-0090 D10 rule 4 declares dual attribution — every write records the agent
that performed it beside the user it acted for. The declaration had no writer:
`assembleExecutionContext` consumed the OAuth `azp` as a boolean and dropped
the value, so a `sys_audit_log` row written by an MCP OAuth client acting for a
human was byte-identical to a row that human wrote in the Console.
Five files, four packages — the whole envelope to row path, because declaring
the key without any one of the copies is the declared-and-never-populated
defect ADR-0049 exists to stop:
- spec/kernel/execution-context.zod.ts — `performedBy: { clientId }`
- spec/data/hook.zod.ts — `provenance.performedByClientId`
- core/security/assemble-execution-context.ts — populate on the existing
agent branch; the field joins the closed entry set
- objectql/engine.ts — `buildProvenance` copies it into the closed hook-context
literal's `provenance` value
- plugin-audit/audit-writers.ts — `metadata.performed_by` + `on_behalf_of`,
present only on a delegated write
Attribution only. `user_id` stays the human (ADR-0073 D3 — the human stays the
owner and the authorization subject) and `actor` is untouched: ADR-0118 D1/D5
keeps that column two-valued and answers "which non-user acted" with an added
attribution field rather than a second actor vocabulary.
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
… one run
A non-vacuous pin has to start at the real door. `buildSession` returns
`undefined` for a context with nothing session-worthy, and the writer's
`ctx.session ?? {}` then resolves every identity read to `undefined` without
throwing — so a hook-layer assertion passes on the early-out while measuring
nothing. This fixture drives `assembleExecutionContext` -> engine.insert ->
buildProvenance -> writeAudit -> the sqlite table, and asserts BOTH rows in one
run: the delegated write carries `performed_by` + `on_behalf_of`, the personal
write carries no delegation, and the two are no longer identical on the
attribution-bearing columns.
Also regenerates the three spec artifacts the field add moved: the kernel
authorable-surface shard, the reference docs, and the strictness-ledger count.
Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
Clause-2 is `yes`, so the grade is minor rather than patch. Measured publish reach per package rather than assumed: @objectstack/spec ships the carrier sources verbatim (`files[]` contains `src/**/*.zod.ts`; the edited `src/kernel/execution-context.zod.ts` is in the tarball while `src/index.ts`, `src/kernel/index.ts` and `src/kernel/cli-extension.test.ts` are on disk and absent from it), and core / objectql / plugin-audit publish `dist`, where the changed text lands in 4 / 6 / 2 built files against a zero-hit negative control. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…ity pin The frozen `legacy*` transcriptions are a pin, not a second implementation, and the file's own header forbids keeping them up to date — the day one needs editing is the day a face's output changed and the change owes its own argument. So the delta is subtracted at the assertion instead, where it can be argued: ADR-0090 D10 rule 4 puts the performing client on the agent face and on no other. Subtraction alone would be a hole to hide the next drift in, so `performedBy` is asserted positively on the next line — present exactly on the agent branch, absent everywhere else, carrying that client's id. Every other key still compares byte-for-byte, and the 64 cases that moved were all and only the agent combinations. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6ecf499d44b7405cb32c84f3f809191601b546f6 && git checkout 6ecf499d44b7405cb32c84f3f809191601b546f6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b57671edc1166d49be40993000894128b3a63b44 0c88ca8db9057488420a315474de72a5097b8165 && git checkout -B drift-repro b57671edc1166d49be40993000894128b3a63b44 && git merge --no-ff 0c88ca8db9057488420a315474de72a5097b8165
node scripts/docs-audit/affected-docs.mjs --json b57671edc1166d49be40993000894128b3a63b44
|
Contract reviewServed-tier: Isolated at-tier review. Inputs: card #17022 and its 15 comments, the rulings they cite (ADR-0090 D10 rule 4, ADR-0118 D1/D5, ADR-0073 D3, ADR-0049), and this PR's body, diff, four commits and check runs. Every seam below was read in the repository at the PR base ( ① Derived judgmentsAccept-set and public-surface changes the diff implies, one by one:
No declared-and-never-populated key is left anywhere on the declared face. ② Semver level
I could not re-run the dist byte measurements (4 / 6 / 2 built files) — this review checkout has no installed toolchain. Reachability was verified statically: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing seat: boundary escalation 2 is discharged — the successor has a number, and it is #18374Posted by the The contract review ( ⇒ Taking the first branch. The successor is #18374 — 「ADR-0090 D10 rule 4 declares It records what this PR deliberately did not build and why: ⇒ The pointer in this PR's body is live as of this comment, and the closing record will name it. ⭐ This seat filed #18374 before the review raised the flag, not because of it — two independent readings reaching the same gap is the reason it is worth a card rather than a sentence. Still owed at acceptance, ⛔ not discharged by this commentBoundary escalation 1 stands as the review wrote it: the booted MCP OAuth door remains unmeasured. The review judged the in-process pin sufficient for this PR's claim — exactly one of four Boundary escalation 3 (pre-existing out-of-face edges: Generated by Claude Code |
Provenance — carriers cleared, landing pre-check passed
Record of record: comment Tier, verified per transcript rather than from the record's own line. The reviewer's transcript reads 123 of 123 Independence pair, machine-readable in the record: Landing pre-check, all three, re-measured at 2026-09-16T06:15Z:
Carriers cleared this stroke, both sides, each a four-step write with a read-back diff: PR #18371 and card #17022. ⛔ Neither was cleared before the verdict was in. Still owed at acceptance and ⛔ NOT discharged by this landing: the booted MCP OAuth door remains unmeasured (boundary escalation 1). The successor for rule 4's undelivered run-id element is #18374 (boundary escalation 2, discharged at Generated by Claude Code |
Fixes #17022
ADR-0090 D10 rule 4 — 「Dual attribution: every write records
performed_by(agent) +on_behalf_of(user) + run id; explain (D6) reports both sides of the intersection.」 — was declared with no writer. Asys_audit_logrow written by an MCP OAuth client acting for a human was byte-identical to a row that human wrote in the Console. The envelope carried the delegation; the row did not; nothing in between copied it.The shape chosen, and why
The dispatch left the shape to this round inside three fences. Chosen:
user_id= the human (unchanged) +actor= the human (unchanged) +sys_audit_log.metadatagains{ performed_by, on_behalf_of }on a delegated write, and nothing at all on a personal one.packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, a sixth path outside the declared face, and it pulls in new field labels, the nine i18n bundles and DDL on a shipped system object. The standing order is to stop and report before writing a sixth path, so this round did not write one.actor = the client, although the dispatch named it as an option. ADR-0118 D5 refuses it in terms: 「actor 维度只有两类:某个用户 / 系统(null)。不引入actor_kind枚举列;追溯「是哪个自动化干的」用既有关联字段(flow run id、job 名、来源上下文),不在 actor 上重复表达——那是双源」. Its own upgrade path names the remedy: 「将来若出现区分多个非用户 actor 的真实业务需求,以新增归因字段(加法)满足,不推翻 null 表示」. So the acting client goes on an added attribution field, never ontoactor. That also discharges triage's additive-only fence verbatim — no existing column changes value, and no historical row is rewritten.metadatais already the declared home for exactly this. The column is declared on the shipped object ('JSON-serialized additional context') and the sibling writer in this same package (auth-event-audit.ts) already stamps it. Nothing new is declared on the object, so nothing new can be declared-and-unenforced.on_behalf_ofis written from the sameuserIdthat lands in theuser_idcolumn, not from a second carrier. Rule 4 asks for both sides on the row; one source for one fact keeps them from ever disagreeing.user_idperformed the write itself.Measured file face — five files, four packages, exactly the declared face
packages/spec/src/kernel/execution-context.zod.tsperformedBy: { clientId }, optionalpackages/spec/src/data/hook.zod.tsprovenance.performedByClientId, optionalpackages/core/src/security/assemble-execution-context.tsENTRY_EXECUTION_CONTEXT_FIELDSpackages/objectql/src/engine.tsbuildProvenance's fixed copy listpackages/plugins/plugin-audit/src/audit-writers.tsPlus two test siblings of files 3 and 5 (
assemble-execution-context.test.ts,agent-dual-attribution.integration.test.ts), one changeset, and three regenerated spec artifacts (authorable-surface/kernel.json, threecontent/docs/references/**pages, the strictness-ledger count). Thirteen paths total, anddispatch-gates.mjsprints the same thirteen from the merge base.No sixth source path appeared. Two readings worth recording, both of which could have produced one and did not:
provenanceis already one of the nine (engine.ts:9981and its four siblings), so the entire fix at seam 4 is insidebuildProvenance's fixed copy list. Re-measured, not carried from the record.actoris untouched.read-audit.ts'srow.actor = event.actor ?? event.userIdkeeps answering exactly what it answered before, and the audit-log browser in the sibling repo is not implicated.audit-writers.ts:1345(user_id) and:1464(actor_id); the three builders areengine.ts:4043/:4104/:4167.⛔ What is NOT delivered: rule 4's third element, the run id
Named rather than quietly dropped, and deliberately not declared. Nothing on the request path mints a run id today —
ExecutionContext.traceIdis declared but sits inNonEntryExecutionContextField, so no transport entry point resolves it, and the only producers in the tree are the observability request-context and the logger. Declaring a carrier nothing populates is the ADR-0049 defect this card exists to close, so the third element is reported as an open gap for a follow-up card rather than half-built here. Both shapes the dispatch named for this round covered the two-sided attribution only.Clause-②:
yes— settled before the first stroke, carrier hung on the cardNot re-litigated. Re-measured this round on the two grounds that survive, with the third restated as the correction it is:
npm pack --dry-run --jsonoverpackages/specenumerates 2012 files andfiles[]literally containssrc/**/*.zod.ts, so the editedsrc/kernel/execution-context.zod.tsis in the tarball while three negative controls are on disk and absent from it (src/index.ts,src/kernel/index.ts,src/kernel/cli-extension.test.ts) ⇒ the probe discriminates both ways.kernel/ExecutionContextrecorded 31 keys before this change and 32 after — re-measured rather than carried; the record's earlier reading of 30 has drifted.check:authorable-surfacemoved and was regenerated.check:api-surface, and that gate is green here. The 2026-09-12 correction holds as measured:api-surfaceis a breadth snapshot of exported NAMES. Grepped overpackages/spec/api-surface/,ExecutionContextSchemareturns 1 while field names return 0 each —performedBy0, and the existingonBehalfOf0 andprincipalKind0 ⇒ the probe finds an export name and provably does not find a field name. The gate does not move on a field add.needs:contract-reviewis on the card, applied by the seat before dispatch, and is applied to this PR in the same stroke that opened it. The PR owes an at-tier contract review from its first stroke.Changeset — measured per package, not assumed
minoron all four (yesgrades abovepatch).@objectstack/specships the carrier sources verbatim, per the tarball reading above.@objectstack/core,@objectstack/objectqland@objectstack/plugin-auditpublishdist, and the changed text reaches it in 4 / 6 / 2 built files against a negative control marker that returns 0;src/index.tsis on disk and absent from all three tarballs.Verification
Every heavy run went through
scripts/pm/os-verify-lock.sh; verdicts are its ownVERDICT command-exitlines, never a bare exit code.Tests — all four affected packages, their own suites, all green:
@objectstack/specTest Files 482 passed (482)·Tests 13688 passed (13688)·VERDICT command-exit 0@objectstack/coreTest Files 51 passed (51)·Tests 1316 passed (1316)·VERDICT command-exit 0@objectstack/objectqlTest Files 298 passed (298)·Tests 4990 passed (4990)·VERDICT command-exit 0@objectstack/plugin-auditTest Files 24 passed (24)·Tests 346 passed (346)·VERDICT command-exit 0typecheckfor the same four in one run:VERDICT command-exit 0.The pin —
packages/plugins/plugin-audit/src/agent-dual-attribution.integration.test.ts, 5 cases, realObjectKernel+ realObjectQLPlugin+ realSqliteWasmDriver+ the real shippedSysAuditLog+ the realinstallAuditWriters, rows read back through the driver's own SQL. It drives the chain fromassembleExecutionContextand asserts both rows in one run, because a hook-layer pin is vacuous by default:buildSessionreturnsundefinedwith no envelope and the writer'sctx.session ?? {}then resolves every identity read toundefinedwithout throwing. The anti-vacuity control is thatuser_idequals the real human on both rows — a value that can only have come through that channel.Ablation of seam 4, the step whose omission would be the declared-and-never-populated defect. This suite resolves
@objectstack/objectqlthrough itsexports(i.e.dist/) by design — a registered pair incheck:test-source-alias'sKNOWN_UNALIASED_TEST_IMPORTS— so the mutation was proven onto disk and into the built artifact before its colour was allowed to mean anything:Direction as predicted: turns red — and the shape is the informative part. Exactly the two cases that assert the card went red; the three controls stayed green, which is correct, because a control that moves with the fix was never a control. Restore leg: whole-tree
git status --porcelainempty,git hash-objectequal to the HEAD blob, objectql rebuilt, andablation-dist-preflightin positive mode exit 0 with the marker back indist/core.js,dist/core.mjs,dist/index.js.A second reverse reading, unplanned and worth naming: the
#6216parity pin inassemble-execution-context.test.tswent red on 64 cases, and they were all and only the agent combinations (2 authz shapes × 4 agent OAuth shapes × 4 localizations × 2 request locales). Every non-agent case stayed green. That is an independent measurement that the new key lands on the agent face and on no other. The frozenlegacy*transcriptions were not edited — the file's own header forbids keeping them up to date — so the delta is subtracted at the assertion and then asserted positively on the next line:performedByis present exactly when an authenticated principal's token names a client, absent otherwise, carrying that client's id. Every other key still compares byte-for-byte.Lint — the whole repo, not a narrowing:
eslint . --no-inline-config --format jsoninspected 6783 files (the set decided byeslint.config.mjsitself, counted from the JSON report) with 0 errors and 0 warnings.Gates.
dispatch-gates.mjs --repo objectstack-ai/objectstackderives 119 families from the real 13-path change set; 20 run with exit 0 recorded ascommand :: exit N, 2 NOT MEASURED, 97 declared to CI. Run and green:check:generatedforpackages/spec(all 15 artifacts up to date,check:api-surfaceandcheck:authorable-surfaceamong them) ·check:nul-bytes·check:test-source-alias·check:durability-log-level·check:published-files·check:empty-changeset·check:changeset-no-major·check:adr-0087-registration·check:spec-docblock-symbol-anchors·check:closing-keyword-parity·check:doc-frontmatter·check:docs-section-name·check:doc-anchors·check:docs-spec-enumerations·check:quick-reference-counts·check:type-check-coverage·check:pm-widening-tells·check:spec-parsed-alias·check:engine-double-contract·pnpm lint.NOT MEASURED (2), and neither is a pass:
check:i18nandcheck:type-check-debtboth answeredexit 3 PREREQUISITE NOT MET— the first wants the built CLI plus a ten-package build closure, the second wants its own built closure. Neither is a finding and neither is green; CI builds both prerequisites.Acceptance notes
check:cross-package-test-inputsreds in this worktree, and the finding namespackages/cli/test/init-created-files-summary.e2e.test.tsdescendingpackages/spec/dist/. Neither path is in this diff,packages/cliis untouched entirely, and no glob declaration moved. The shared checkout answers exit 0 only because it has nopackages/spec/distat all, so that green is vacuous for this question rather than a control — the gate's verdict is a function of local build state. Recorded, not filed by this round, not repaired here.sys_activitymirror row is deliberately left alone. Itsmetadataalready carries{ old, new }, and the activity timeline is a user-facing feed rather than the compliance ledger this card is about.auditRow.metadatais stamped unconditionally, matchingauth-event-audit.tsin this same package, rather than behind anobjectHasFieldprobe likeorganization_id/actor. Those two are probed because older audit tables predate them;metadatadoes not. A genuinely missing column fails the INSERT loudly through the existing reporting seam instead of silently dropping the delegation.Generated by Claude Code