Repository navigation
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 |
… is a credential (objectstack-ai#18335) (objectstack-ai#19322) Fixes objectstack-ai#18335 Clause-②: no ⛔ **Governed surface — this PR parks as a draft by design.** `docs/adr/**` is Tier H (Prime Directive objectstack-ai#14). No ready-flip, no enqueue, no auto-merge, and no approval by any agent seat. An authorized human approval is owed before this lands; a draft with the work done is the complete deliverable. ## What this lands ADR-0090 D10 rule 4 read 「every write records `performed_by` (agent) + `on_behalf_of` (user) + run id」. No door has ever read it that widely, and the gap had never been written down. Per the ruling on the card (comment 5690859150, batch objectstack-ai#139 item 1, letter **A**, maintainer 「同意」 2026-09-16, reaffirmed at 5731818788), this PR closes it documentarily: 1. **Rule 4 is narrowed** to *every write by an agent principal*, and it now says what an agent principal IS — a caller a door resolves to `principalKind: 'agent'`, today the OAuth / MCP client door alone — so a later reader can classify a new caller type without re-litigating. It also states the positive reading of the absence: a missing `performed_by` is the record that the principal acted for itself. 2. **A dated note** at the end of D10 (`Note (2026-09-16, objectstack-ai#18335)`) records that an API key is its owner's **credential**, not an agent principal, and *why* — a credential is how a principal acted, never a second who — plus what was refused (API keys as a principal category of their own) and on what basis. Documentary only. **No code**: one file, +47 / -2. ## The ruling's premise, measured rather than inherited The ruling asserts that API-key writes audit as the owner today. A note that misdescribed the enforcement would recreate the very defect this card closes, so the assertion was measured first. All readings against `origin/main` at base `e3b3cdd`, taken 2026-09-20T11:05–11:15Z. | reading | result | |---|---| | the one seam that produces an agent principal | `packages/core/src/security/assemble-execution-context.ts#entryFields` — `const agent = !anonymous && oauth?.clientId ? oauth : undefined`, consumed by `principalKind`, `onBehalfOf` and `performedBy` | | `principalKind` / `onBehalfOf` / `performedBy` in `packages/core/src/security/api-key.ts` | **0 / 0 / 0** — lit control: `userId` reads **6** in the same file | | the same three in `packages/core/src/security/resolve-authz-context.ts` | **0 / 0 / 0** — lit control: `userId` reads **48** in the same file | | the same three in `packages/runtime/src/security/api-key.ts` | **0 / 0 / 0** (a 25-line re-export module) | | which doors honour an API key, and what each hands the assembler | **all three** that run `resolve-authz-context.ts#resolveAuthzContext`. REST and MCP **stdio** pass `oauth: undefined` **by construction** (`rest-server.ts`, `mcp/src/plugin.ts#resolveStdioExecutionContext`); the runtime / MCP **HTTP** dispatcher excludes keys with a **guard**, `resolve-execution-context.ts#extractJwtBearer`, refusing an `osk_`-prefixed or non-JWT bearer | | repo-wide non-test writers of `performedBy` | the MCP/OAuth seam, the spec + hook declarations of the field, and the audit writer that reads it. No API-key path | ⇒ an API-key caller falls through `agent ? 'agent' : anonymous ? 'guest' : 'human'` to `human`, carries no `onBehalfOf` and no `performedBy`, and its `sys_audit_log` row is the owner's own. **The measurement agrees with the ruling's premise**, so the narrowing describes the enforcement rather than changing it. ## Was there a dangling D6 sentence? No. The clause 「explain (D6) reports both sides of the intersection」 lives *inside* rule 4, so the narrowing carries it. Measured on the ADR: `attribution` occurs once in the whole file (rule 4) and `both sides` once (its second line). The companion `docs/design/permission-model.md` does not restate the rule at all — `performed_by` / `performedBy` / `attribution` / `every write` read **0** there, against a lit control of **14** for `agent`. The generated `content/docs/references/**` tables carry the field's own `.describe()` text, which already says 「Set only at the /mcp OAuth door … absent everywhere else」 — already narrow, nothing to correct. ## Gates Derived in this worktree from the real change set and reconciled with the run log: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack # 18 commands node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_FILE Run reconciliation — 18 derived, 18 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 18 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` All 18 exit 0, each captured to disk **before** any pipe. `check-adr-links`, `check-adr-symbol-anchors` (+ both self-tests), `check:adr-anchors`, `check:doc-authoring`, `check:nul-bytes`, `check:pm-governed-merges`, `check:pm-prior-rulings`, `check:comment-mask-corpus`, `check:cross-package-test-inputs`, `check:driver-memory-census`, `check:refd-timer-probe`, `check:watch-hint-literal`, `check:ci-filter-parity`, `check:closing-keyword-parity` (+ self-test) are among them. `check:doc-formula-expressions` first exited **3** — `PREREQUISITE NOT MET`, the gate's own "nothing was measured" code — and was re-run to exit 0 after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint`, taken under the shared verify lock. The three new symbol anchors in the note (`#entryFields`, `#resolveApiKeyAdmission`, `#resolveAuthzContext`) resolve as `declaration` class; no line-number anchor was introduced. ## 维护者速读(草稿) **改了什么。** 只动一份决策记录(ADR-0090),一个文件,47 行增、2 行删。一句原本写着「**每一次**写入都要记下『谁代谁干的』」的规则,被收窄成「**代理主体**的每一次写入」;同一节末尾新增一段带日期的说明,写明 **API key 不在这条规则里,以及为什么**。⛔ 没有改任何代码,平台行为一行都没变。 **为什么改。** 平台里只有一个地方会把调用方判成「AI 代理」:MCP 的 OAuth 门。**三个门都认 API key**,但没有一个会把它变成代理:REST 与 MCP stdio 结构上就不传 OAuth 凭据,MCP HTTP 门则靠一道两行的**守卫**挡住 `osk_` 开头的 bearer。三条路都只认出**钥匙的主人**,所以审计日志记的就是主人本人——这一点本轮在源码上实测过,与裁定的前提一致。于是规则写的是「每一次」,实现做的是「只有代理那一次」,这就是**声明面与执行面对不上**。维护者已裁 A(API key 是主人的**凭证**,不是第二个「谁」),裁定里同时明写:只维持现状而不改那句话,等于新留一条「说的和做的不一致」。这份 PR 就是把那句话改对,并把理由记在案。 **风险与代价(含回滚)。** 风险低:纯文档,无运行时、无 API、无数据结构变化,不发布任何 npm 包。真正的代价是**语义上的**:这条规则从此明确**不**覆盖 API key。将来若出现合规要求「要分得清是人按的还是钥匙跑的」,那是一张新卡、新决策,而不是重读这一条——这一点也写进了 Note 里。回滚是一次 `git revert`,零迁移、零数据影响。 **席位意见。** **你要做的(一个动作)。** 读一遍 D10 rule 4 那六行和它后面那段带日期的 Note,回「同意」或指出要改的措辞。这是受管面(`docs/adr/**`,Tier H),在你点头之前它会一直停在 draft。 ## Acceptance notes - **`skip-changeset` is owed and was deliberately NOT applied.** This diff publishes nothing (`docs/adr/**`), so `Check Changeset` needs that label; the dispatch forbids this executor from touching any label on a governed-surface PR, so the label is left to the seat. Until it is applied, `Check Changeset` is expected red and that red is not a finding about this diff. - **Noted, not filed — D10's 2026-07 Status blockquote still lists 「the agent audit-provenance gap」 as an open follow-up.** That gap is the one objectstack-ai#17022 covered, and that card is `closed`/`completed` (2026-09-16T06:41Z). This is a stale pointer in prose, not a reproducible defect, not a contract violation and not a metadata-authoring trap, so it is not one of the three filing classes. Carrier: **objectstack-ai#18374**, which already owns the residual close-out in the same D10 area. - **Seat assumption 2 re-measured wider than dispatched.** The dispatch named four PRs; all **36** open PRs in the repo were read at 2026-09-20T11:10Z (`GET /pulls/{n}/files`). Exactly one touches `docs/adr/**` — objectstack-ai#18985, on ADR-0089 and ADR-0137, disjoint files. Zero open PRs hold ADR-0090. One caveat recorded rather than hidden: PR objectstack-ai#17076 (`chore: version packages`) has more than 200 files; pages 1 and 2 were read (200 files, zero `docs/adr/` hits) and the tail was not enumerated. - **A small line drift in the dispatch's measurement table, reported as instructed.** The card body cites the agent channel at `:293` consumed at `:316`/`:317`; on `e3b3cdd` those are `:294`, `:317`, `:318` — the file gained the `performedBy` line from objectstack-ai#18371. The rule 4 sentence itself was anchored on its text and matched verbatim, at line 391 as the seat read it. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…s reopen condition (objectstack-ai#19653) Fixes objectstack-ai#18374 ## 维护者速读(草稿) **改了什么** —— ADR-0090 D10 的 `agent` 规则 4 要求每次 agent 写入记三样:谁干的、代谁干的、**哪一次运行**。前两样已在产,第三样没有。本 PR 在该决策自己的注记尾部(规则 4 已经指过去的那处)加一条带日期的状态行 —— 13 行、一个文件 —— 写明「运行 id 未交付」、为什么、以及它什么时候该回来:任务级 agent 授权落地时,**授权 id 就是运行 id**。⛔ 规则 4 自己那句话一个字没动。 **为什么改** —— 执行裁决评论 `5749155184`(总监席 batch objectstack-ai#195 item 2,letter **C**,维护者「同意」2026-09-20)。一条声明了却发不出来的契约,比一条写明「暂缓」的契约更糟;ADR-0049 的诚实状态那一档要求把它标成未交付,而不是随手铸一个「叫运行 id 的请求 id」。 **风险与代价(含回滚)** —— 纯文档:零运行时、零 spec、零发布面。回滚 = revert 这一个提交。真正的代价是未来读者会看到一条明写「未交付」的规则 —— 这正是目的,而不是副作用。 **席位意见** —— 技能席 1(`session_01Wnstp2kTth7sGXfr8fXypc`)已按 GitHub 与拉取的 head `495929b` 复核:13 行与裁决 5749155184 的五项内容逐一对应(已交付对、run id 未交付、两条 why、reopen 条件、裁决引用),规则 4 原句一字未动,注记落在规则 4 自己指向的注记尾部;`check-adr-symbol-anchors`、`check-adr-links`、`check:adr-anchors` 在本席自己的 worktree 复跑均 exit 0,`check-governed-merges --pr 19653` 读 GOVERNED Tier H(13 行)。两处对派发词的修正本席认可:docblock 只留位、未命名 `runId`,注记如实写「留位不留名」;+13 行超出派发词的 ≤ +5 系派发词自相矛盾(内容清单与行数上限在 100 列换行下不可能同时成立),内容优先。建议合并;`Fixes objectstack-ai#18374` 合入即关卡,`pm:dispatched` 由本席在合入时摘。 **你要做的(一个动作)** —— 读那 13 行,认可就手工合并。`docs/adr/**` 是 Tier H 受管面,本 PR 保持 draft,⛔ 没有 AI 席位可以代你合、代你入队或给它挂 auto-merge。 Clause-②: no ## What landed One dated status note in `docs/adr/0090-permission-model-v2-concept-convergence.md` — **13 lines added, 0 removed, 1 file**. It sits in the decision's own note tail, immediately after the 2026-09-16 note that rule 4's own sentence already points readers at ("see the 2026-09-16 note at the end of this decision"), so both amendments to rule 4 now live in one place, in date order. Rule 4's sentence is byte-unchanged. The note states, in the ruling's order: `performed_by` and `on_behalf_of` ship (objectstack-ai#18371); no run id is stamped and none is pending; why (no agent-run concept on the request path — the MCP door is explicitly stateless — so no server-constructed identifier grouping one agent task exists to carry, and the two correlation ids that do travel that path, `traceId` from the client's `traceparent` and `requestId` preferring the client's `X-Request-Id`, are caller-controlled, which the envelope refuses in writing); the reopen condition (task-scoped, time-boxed agent grants land ⇒ the grant id is the run id ⇒ a successor wires `performedBy.runId`); and the ruling reference in the file's own `Ruling:` form. ## Two measured corrections to the dispatch's premises **The shipped docblock reserves the ROOM, not the NAME.** `packages/spec/src/kernel/execution-context.zod.ts#performedBy` reads verbatim: "A one-key object rather than a bare string so the API-key door (objectstack-ai#18335, blocked on this carrier) can name its own identifier as a sibling key if it is ruled an agent, without re-shaping a field that already shipped." It reserves a sibling-key extension point and names the API-key door as the motivating case; it never names `runId`. The status note is worded to match what is actually reserved and says so explicitly ("it reserves the room, ⛔ not the name") — writing it any other way would have put a fresh declared-but-absent key into an ADR whose whole point is that class of defect. **The line budget could not hold the enumerated content.** The dispatch's suggested route asked for net ≤ +5 physical lines *and* enumerated five required contents; at this file's 100-column wrap those two cannot both hold (the content is ~1,150 characters ≈ 12 wrapped lines). Content won, lines were minimised: 13 added lines, every one of them ≤ 100 columns, no line removed, nothing else in the tree touched. ## Gates Derived mechanically, not recalled: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths — the script took its own change set from the merge base), 19 families. All 19 run, all exit 0, reconciled with `--ran`: `✓ dispatch-gates --ran: 19 derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED (a DERIVED zero — all 19 recorded an exit code and none of them is 3).` Named readings from that set: - `node scripts/check-adr-symbol-anchors.mjs` :: exit 0 — `✅ check-adr-symbol-anchors: 2121 anchors across 140 records resolve` - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:adr-anchors` :: exit 0 - `pnpm check:pm-governed-merges` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 (plus a hand scan of the inserted text for control bytes: no match) One family needed a prerequisite: `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first exited **3** — its own text says "Nothing was measured: this gate exited before running a single check", naming two unbuilt workspace packages. After `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` (through `scripts/pm/os-verify-lock.sh`, `VERDICT command-exit 0`) it re-ran at **exit 0**. The exit-3 reading is recorded as NOT MEASURED, ⛔ not as a failure. Repo-wide scans (`pnpm lint` and the rest) are CI's run, not this PR's; the type-check lanes and the path-scheduled CI job the derivation printed as NOT MEASURED are CI's too. ## Reverse verification — the new citation is actually checked The note cites `packages/spec/src/kernel/execution-context.zod.ts#performedBy`, and `check-adr-symbol-anchors` resolves that spelling. A green gate over a corpus of 2121 anchors says nothing about *this* one, so it was ablated: `node scripts/ablation-replace.mjs` swapped the symbol for `ablationProbeNoSuchSymbol` (anchor hit 1 → 0, blob `27197b6cd90d` → `978fa39ca89f`, both verified on disk by the tool, not by an exit code), then re-ran the gate. Predicted direction: turns red naming this line. Observed: ``` ❌ check-adr-symbol-anchors: 1 finding(s) across 140 records. [unresolved-symbol] docs/adr/0090-permission-model-v2-concept-convergence.md:455 `packages/spec/src/kernel/execution-context.zod.ts#ablationProbeNoSuchSymbol` `ablationProbeNoSuchSymbol` has no declaration site or string-literal token in `packages/spec/src/kernel/execution-context.zod.ts` ``` Restore leg: `ablation-replace: ok restored: blob == HEAD (27197b6) and 'git diff HEAD' is empty`. Both legs ran from the committed state; no probe file survives. (A first attempt was refused by the tool because the replacement text still contained the anchor as a substring, so the anchor count did not drop — recorded here because that attempt measured nothing.) ## Changeset — `skip-changeset`, measured `docs/adr/**` publishes nothing. Measured rather than assumed: of 83 tracked `package.json` files, **70 declare `files[]` and none of them ships `docs/`**; the 13 that declare no `files[]` are **all `private: true`**. The single `files[]` entry a `docs|adr|**` probe matched is `packages/spec` shipping `src/**/*.zod.ts`, which is not a docs path. ## Landing posture — Tier H, stays draft `docs/adr/**` is Tier H (人合) on the `GOVERNED_SURFACES` register in `scripts/pm/check-governed-merges.mjs`. This PR is opened draft and stays draft: the maintainer merges it by hand. ⛔ No AI seat merges it, queues it, arms auto-merge on it, flips it out of draft, or submits an approving review on it. ## Acceptance notes - The zero-commit remote branch `claude/issue-18374-run-id-entry-carrier` (`564ac2f170`, the `domain:spec` seat's write-path probe) is still on the remote. It is a **stale marker, not in-flight work**. Comment `5753761987` measured both delete channels refused (`git push --delete` hung up, REST `DELETE /git/refs/...` answered 403), so this run did ⛔ not re-attempt the delete. - Observation only, ⛔ no card filed: the 2026-07 status blockquote at the top of D10 tracks "the agent audit-provenance gap" as a follow-up with **no tracker number beside it**, unlike the `objectstack-ai#2849` in the same sentence. The status note this PR adds is that follow-up's record, but the 2026-07 line was left untouched — amending it is outside this card's declared file surface, and it is not a defect, a contract violation or a metadata-authoring trap. - Concurrency re-checked at this branch's base: all 18 open PRs' file lists read; none touches `docs/adr/0090-permission-model-v2-concept-convergence.md`. `origin/main` had not moved from `80ca0b1c88`, so no merge was owed. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
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