Skip to content

feat(spec,runtime,plugin-audit): an action run through MCP leaves an audit row naming its surface - #28171

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-27908-action-audit-surface
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-27908-action-audit-surface

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V

Fixes #27908

Clause-②: yes (widening)
Metadata-protocol: added

维护者速读

  • 改了什么: 每次通过 REST 动作门(控制台按钮、SDK)或 MCP(AI agent 的 run_action / resume_run)调用业务动作,审计日志 sys_audit_log 都多一行 action_invocation,metadata.surface 写明 rest 还是 mcp;agent 代人调用时同一行还带 performed_by / on_behalf_of。四个分支(脚本、api 转发、flow、声明式更新)都写,成功和分派后被拒都写。
  • 为什么改: 执行 mcp: run_action refuses every action on a sys_* object, so an agent cannot reach the identity actions (ban_user, impersonate_user, set_user_password) that REST POST /actions serves #27681 裁决 6513869839 参数 3:管理员要在审计日志里分清"agent 调的"和"人点的",不需要第二份日志。之前只有脚本分支打一条 console.info,其余三个分支什么都不留。
  • 风险与回滚: 只新增审计写入,不改授权、不改哪个门能跑哪个动作;审计写失败只打一次 error 日志,动作本身的结果不变。回滚即回退本 PR,已写入的审计行留在账本里。
  • 先看: ① packages/spec/src/kernel/execution-context.zod.ts 新键 surface(取值 rest / mcp);② packages/runtime/src/action-invocation-audit.ts(门如何盖上自己的 surface、为何 api 转发不需要额外的头);③ packages/plugins/plugin-audit/src/action-invocation-audit.ts(审计行的形状)。
  • 两分钟验证: pnpm --filter @objectstack/runtime exec vitest run src/action-invocation-audit.test.ts,预期 Tests 24 passed:四个分支 × 两个门各写一行,伪造的转发头和上游 surface 都改不了记录的门。
  • 需要你做的: 批准键名 ExecutionContext.surface 及其取值 rest / mcp(连同 sys_audit_log.action 新值 action_invocation)。

Independent review: none run (quota); the PR waits for the seat's contract-review hand-off.

What changes

The key — ExecutionContext.surface (@objectstack/spec). z.enum(['rest', 'mcp']).optional(), with the performedBy docblock discipline (ADR-0090 D10 rule 4): set only by the two action doors, from the door's own identity, on the envelope of the invocation they record; never client-supplied; attribution only (no middleware reads it, never an authorization subject, scope or gate). The enum is closed to the doors that invoke an action today: rest (REST POST /actions/:object/:action) and mcp (the /mcp bridge's run_action / resume_run). No other door invokes a business action: flow action nodes and the declarative endpoint executor do not route through either door (measured: invokeBusinessAction has one caller, domains/mcp.ts). The maintainer approves the key name and its values in this PR. @objectstack/core lists it as a non-entry field (identity resolution does not produce it; the same credential reaches both doors).

The doors (@objectstack/runtime). action-invocation-audit.ts holds the door half: withActionSurface(ec, surface) (a COPY of the caller's envelope with the surface overwritten, so the request's own envelope is unchanged and nothing upstream can move the value) and recordActionInvocation, which resolves the audit service slot and hands it one event. Each door's post-gate dispatch is split into its own function, code unchanged, so the door can write the row for whatever that dispatch answers:

  • REST: handleActionsRequest → dispatchAdmittedRestAction; the row reads the answered status (and error.code).
  • MCP run_action: invokeBusinessAction → dispatchAdmittedBusinessAction; the row reads the thrown error through resolveThrownHttpError.
  • MCP resume_run: resumeActionRun → continueAdmittedRun (steps 4–6); the row names the admitting flow action and carries the run_id.

Which calls write a row, and why. Every call that cleared its door's admission gates and reached its branch: the branch's success, and every refusal from there on (the subject-record verdict, a declarative write the data plane refused, an api target's non-2xx, the api loop refusal, a flow run that failed, a body that threw, "no handler registered"). A call refused by an admission gate (not found, the sys_* / ai.exposed gates, ADR-0066 D4 requiredPermissions, the ADR-0126 activation switch, the param contract, the confirmation gate, and for resume_run steps 1–3) ran nothing and writes no row: the ruling asks to tell an agent's run from a click, and those calls ran nothing.

The row (@objectstack/plugin-audit). createActionInvocationAuditWriter joins createAuthEventAuditSink on the audit slot (recordActionInvocation). It fits sys_audit_log with no new field: action: 'action_invocation' (a new value of the existing action select, added writer-first; sys-audit-log-retired-actions.test.ts names its writer), user_id / actor / tenant_id from the envelope by the CRUD mirror's rules, object_name + record_id for the subject (so the ledger's record read gate judges it), no snapshots and no params, and metadata = { surface, action_name, branch, outcome, status?, error_code?, run_id?, performed_by?, on_behalf_of? }. A lost row is reported once per cause at error (persistActionInvocationAuditRow is registered in DURABILITY_CRITICAL_CALLEES). The [action-audit] log line is kept and is not the row.

The api forward. The row on the api branch is written by the door after the in-process forward answers, from the door's own envelope, which the target's re-authentication never replaces; so it names the true door with no surface header on the forward (pinned: the forwarded request carries none, and the row says mcp). See Acceptance notes for why no trusted forward header was added.

Tests

  • packages/runtime/src/action-invocation-audit.test.ts (24): every branch × both doors (REST through HttpDispatcher.handleActions, MCP through the real buildMcpBridge) hands the slot exactly one event with the right surface, branch, outcome; a refusal after dispatch on every branch × both doors records failed with the door's status and code; the subject-record 404 writes one failed row per door; a D4-gated call writes none; the forged-header case (an outside REST request carrying the forward mark, an x-objectstack-action-surface: mcp header and an upstream surface: 'mcp' still records rest; an MCP call arriving as surface: 'rest' still records mcp); the api row after the forward; the request envelope left unchanged; the agent's performedBy beside the surface; resume_run success and refusal rows.
  • packages/plugins/plugin-audit/src/action-invocation-audit.test.ts (7): through a real ObjectQL engine, a click and an agent's call of the same action land as two rows that differ by surface and performed_by; a refusal's status/code and a resume's run id; object-less actions; no guessed surface; the actor-less ledger; the once-per-cause error report with no throw.
  • sys-audit-log-retired-actions.test.ts: action_invocation added to the writer inventory. assemble-execution-context.test.ts: surface added to the non-entry mirror.

Ablation (one-shot, scripts/ablation-replace.mjs, mutation proven on disk, restored by blob hash):

  • runtime: await sink.recordActionInvocation(event); → removed ⇒ action-invocation-audit.test.ts 20 failed | 4 passed (the 4 are the no-row gate case and the helper cases); restored blob d014ccafe5bb.
  • plugin-audit: await persistActionInvocationAuditRow(engine, row); → removed ⇒ 3 failed | 4 passed (every read-back case); restored blob 9d2076906454.

Acceptance notes

  • Metadata-protocol: added, not the claim's none (the order asked to check pr-automation.yml's WHICH LEVEL table and follow it). The table's gate (check-changeset-no-major.mjs, none-baseline-moved) refuses none when an authorable-surface/ shard moves, and this key moves packages/spec/authorable-surface/kernel.json (kernel/ExecutionContext:surface, regenerated by the build — the shard lists every ExecutionContext key). So the declaration is added and every moved package's changeset is minor (spec, core, runtime, plugin-audit).
  • No trusted surface header on the api forward (deviation from the order's suggested route; the seat decides). Measured: (1) the row does not need it, as above; (2) the forward mark the PM assumption leans on carries no trust — any outside caller can send x-objectstack-action-forward, and its only effect is the loop refusal — so a surface header "honoured with the same trust" would be honoured from any caller, letting an agent's call record rest; (3) a sound carry needs a new in-process credential, and each face that resolves an envelope (resolveRequestExecutionContext has seven door callers) would have to honour it; (4) no far-side writer would read it: the CRUD mirror's hook provenance carries flowRunId / attributedUserId / performedByClientId only, and the sys_* identity targets write through better-auth's adapter under its own system context. Carrying the surface onto the CRUD rows of the target's writes is a separate change (HookContext provenance + objectql), not made here.
  • sys_audit_log gains an enum VALUE, not a field. tenant_id is the caller's active organization (ExecutionContext.tenantId); the CRUD mirror prefers the record's own organization, which this row does not read.
  • Read from code, not measured: an OAuth-token agent's api action forward reaches its target with the bearer the dispatcher honours only on /mcp paths (acceptOAuthAccessToken path match, http-dispatcher.ts), so the target would resolve the caller as anonymous. Out of this card; reported to the seat.

Verification

Local gates and tests (head and commands in the os-dev-report comment on #27908)
  • pnpm build (whole workspace, under the verify lock): exit 0
  • @objectstack/spec check:generated: 14/14 up to date
  • typecheck spec / core / runtime / plugin-audit: exit 0
  • tests: core 128 files / 2873 passed; plugin-audit 66 / 1058; runtime 484 / 7895 passed, 19 skipped; spec 756 / 22510 passed, 1 todo; runtime/src/action-invocation-audit.test.ts 24 / 24 on the final head
  • node scripts/pm/dispatch-gates.mjs --commands: 131 derived commands, each run with its exit code recorded before any pipe; --ran reconciliation: 131 derived, 131 run, 0 NOT-MEASURED, 0 UNRUN, every exit 0
  • The first pass was red on six commands (five gates), all fixed in the last commit: check-tenant-audit-census (the new write site; the options cast is dropped so the census reads its system context, and the page is recounted 251 to 252), check:engine-double-contract (ledger --write), check:objectql-double-limit (the test double now applies limit), check:swallow-census-controls (the new durability callee copied into WRITE_SHAPED_CALLEES), check:doc-authoring (issue id dropped from the key's .describe()). Three more first answered exit 3 (not measured: no build or no lock turn) and were measured green after pnpm build: check:skill-examples, check:dual-build-cjs-loads, check:type-check-debt.
  • check-adr-0087-registration.mjs --base origin/main: exit 0 (no declared-breaking changeset)

Generated by Claude Code

…tion-audit-surface

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
Read the audit row's options argument without a cast so the tenant-audit
census sees the system context, recount the census page, pin the new
engine double, bound the test double's find, copy the new durability
callee into the swallow census, and drop the issue id from the surface
key's customer-facing description.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
Resolves sys_audit_log's action select: main labels every option (#27855),
and this branch adds action_invocation, so the new value joins main's
labelled list as { label: 'Action Invocation', value: 'action_invocation' },
with the en bundle's option label to match.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…n_invocation

main's #27855 pin lists every stored value of sys_audit_log.action. This
branch adds action_invocation writer-first, so the pin names it; no
existing value moves.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

维护者速读(席位终稿 · head 1d4e697f)

  • 改了什么: 通过 REST 动作门(控制台按钮、SDK)或 MCP(AI agent 的 run_action / resume_run)调用业务动作时,审计日志 sys_audit_log 都会多写一行 action_invocation,其中 metadata.surface 记 rest 或 mcp。agent 代人调用时,同一行还带 performed_by / on_behalf_of。四个分支(脚本、api 转发、flow、声明式更新)都写,成功或在分派后被拒都写。
  • 为什么改: 执行 mcp: run_action refuses every action on a sys_* object, so an agent cannot reach the identity actions (ban_user, impersonate_user, set_user_password) that REST POST /actions serves #27681 裁决 6513869839 参数 3:管理员在审计日志里就能分清“agent 调的”和“人点的”,不必另看日志。原来只有脚本分支打一条 console.info。
  • 风险与回滚: 只新增审计写入,不改授权,也不改哪个门能跑哪个动作。审计写失败只记一次 error,动作结果不变。回退本 PR 即可,已写入的行留在账本里。
  • 先看: packages/spec/src/kernel/execution-context.zod.ts 的 surface;packages/runtime/src/action-invocation-audit.ts;packages/plugins/plugin-audit/src/action-invocation-audit.ts。
  • 两分钟验证: pnpm --filter @objectstack/runtime exec vitest run src/action-invocation-audit.test.ts,应为 24 passed。
  • 需要你做的: 批准键名 ExecutionContext.surface 和取值 rest / mcp,以及 sys_audit_log.action 的新值 action_invocation。契约复审 PASS 在案后才请批。

席位意见: 按卡交付,ACCEPT(车道部分)。与 triage 路线有一处偏差:api 转发不带 surface 头,由门在转发返回后自己写行,席位接受(理由见下)。

席位核对 · ACCEPT (lane part) · card #27908 · 2026-10-30T15:28Z

  1. 形态: 30 files, +1314 / −56 at head 1d4e697f (own diff), draft, Fixes #27908 once, Clause-②: yes (widening) / Metadata-protocol: added. Changesets minor on spec, core, runtime and plugin-audit. Below 3000 lines; no governed path.
  2. 前提实测: holds on origin/main. ExecutionContextSchema had no surface key, and only the script branch logged [action-audit]. fix(mcp): an action on a sys_* object whose author exposed it to AI is listed and run over MCP, behind every gate #27940 (the mcp: run_action refuses every action on a sys_* object, so an agent cannot reach the identity actions (ban_user, impersonate_user, set_user_password) that REST POST /actions serves #27681 mechanism) merged before the claim.
  3. 门禁 check-runs: the head moved, so none are read on 1d4e697f yet. The seat merged origin/main (mergeable_state was dirty) through os-regen-merge.sh. The one hand conflict was sys_audit_log.action: main labels every option (Setup: System Overview and Notifications show raw values (config_change, approval.requested, lowercase severity) (#27521 row 4) #27855), and the resolution adds { label: 'Action Invocation', value: 'action_invocation' }, plus the en bundle label and main's stored-values pin. Regeneration plus check:generated, check:authorable-surface and check-i18n-bundles exit 0; plugin-audit 1086 tests and tsc exit 0. Dev on c6228a99: 131/131 gates run, all exit 0; runtime 24/24 and plugin-audit 7/7 new cases; ablations 20 red / 3 red. CI on this head answers before any landing stroke.
  4. 接受的偏差:
    • No surface header on the api forward. The door writes the row after the in-process forward answers, from its own envelope, and a test pins that the row says mcp while the forwarded request carries no header. Measured: the forward mark is forgeable by any caller (its only effect is the loop refusal), so a header trusted the same way would let an agent's call record rest. A sound carry needs a new in-process credential, and no target-side writer would read it. The ruling asks for the invocation row, and that row is delivered on every branch. Carrying the surface onto the target's own CRUD rows is a separate change. For the contract reviewer to judge against triage 6514607572's "forward headers" route.
    • Metadata-protocol: added, not the claim's none: the key moves authorable-surface/kernel.json, and check-changeset-no-major refuses none when a shard moves.
    • An enum value only (action_invocation), no new field. tenant_id is the caller's active org. resume_run writes a flow row carrying run_id. The surface is stamped on a copy of the envelope.
    • Rows are written past the admission gates only. Calls a gate refuses (not found, sys_* / ai.exposed, D4, the activation switch, params, confirmation, resume_run steps 1–3) ran nothing and write no row; that is stated in the PR and pinned for D4.
  5. 范围外去向 + Skills-docs:: an OAuth-token agent's api action forward may reach its target as anonymous (acceptOAuthAccessToken matches /mcp paths only, http-dispatcher.ts:753). It was read in code with reach not measured, so the seat files it as its own card. Skills-docs: none: no skill teaches ExecutionContext keys.
  6. 落地条款: owed: green CI on the current head, a same-shape CONTRACT_REVIEW_TIER PASS (the packages/spec/src/** hunk and the Clause-②: yes declaration), and the maintainer's approval of the key, its values and the new action value. Not governed, so once both are on record the seat runs the pre-landing check and lands.

Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (HTTP 429 "You've reached your Fable limit", 2026-10-30T04:26Z) · head 1d4e697

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/plugin-audit, @objectstack/runtime, @objectstack/spec, touching 43 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/audit-log-field-redaction.ts, packages/plugins/plugin-audit/src/audit-log-read-visibility.ts, packages/plugins/plugin-audit/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

53 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a3d24c991f2d793a1600e7f25fb0d5f577ed2fff.

⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/audit-log-field-redaction.ts, packages/plugins/plugin-audit/src/audit-log-read-visibility.ts, packages/plugins/plugin-audit/src/index.ts, …) — pages documenting those are invisible to this run
  • 2 cross-cutting symbol(s) contributed no route anchor: resolveService (11 routes), recordId (4 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 55 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 182 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a3d24c991f2d793a1600e7f25fb0d5f577ed2fff → packageMentionDocs.

Which tree this was computed on

This run read content/docs from dfa78b9d187795ba7ad53ce7d22f6671701b41a8 — the merge of head 1d4e697f863b87ad87999502f43e306c3095a2bb into base a3d24c991f2d793a1600e7f25fb0d5f577ed2fff, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dfa78b9d187795ba7ad53ce7d22f6671701b41a8 && git checkout dfa78b9d187795ba7ad53ce7d22f6671701b41a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a3d24c991f2d793a1600e7f25fb0d5f577ed2fff 1d4e697f863b87ad87999502f43e306c3095a2bb && git checkout -B drift-repro a3d24c991f2d793a1600e7f25fb0d5f577ed2fff && git merge --no-ff 1d4e697f863b87ad87999502f43e306c3095a2bb

node scripts/docs-audit/affected-docs.mjs --json a3d24c991f2d793a1600e7f25fb0d5f577ed2fff

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a3d24c991f2d793a1600e7f25fb0d5f577ed2fff → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head 1d4e697f863b87ad87999502f43e306c3095a2bb · 2026-10-31T02:32Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1d4e697f863b87ad87999502f43e306c3095a2bb
Local-runs: none
Scope: packages/spec/src/kernel/execution-context.zod.ts (ExecutionContextSchema.surface), packages/spec/authorable-surface/kernel.json, packages/core/src/security/assemble-execution-context.ts and its test (the closed non-entry field set), packages/runtime/src/action-invocation-audit.ts (withActionSurface, actionInvocationBranch, actionInvocationFailure, recordActionInvocation) and its 24-case test, packages/runtime/src/domains/actions.ts (handleActionsRequest split into dispatchAdmittedRestAction), packages/runtime/src/action-execution.ts (invokeBusinessAction split into dispatchAdmittedBusinessAction), packages/runtime/src/domains/mcp.ts (resumeActionRun split into continueAdmittedRun), packages/plugins/plugin-audit/src/action-invocation-audit.ts (buildActionInvocationAuditRow, createActionInvocationAuditWriter, persistActionInvocationAuditRow) and its 7-case test, audit-plugin.ts (the audit slot), sys-audit-log.object.ts (the action_invocation option) and the two inventory tests, the four generated translation bundles, audit-log-read-visibility.ts and audit-log-field-redaction.ts (doc blocks), scripts/check-durability-degradation-log-level.mjs, scripts/measure-durability-swallow-family.mjs, scripts/engine-double-contract.pinned.json, the regenerated census pages and reference docs, the changeset.

Inputs: card #27908 (body, triage 6514607572, claim 6521029979, os-dev-report 6526091689 with its seven deviations and two out-of-scope findings), ruling 6513869839 on #27681 (letter B, parameter 3) as the card and claim quote it, the seat's ACCEPT record 6526530153, the PR body, the 30-file diff against merge base 9f13ee8, main's own movement since that base (462 files, three shared with this diff: action-execution.ts where main added the identity-route key refusal in forwardApiAction, a different region from this PR's split of invokeBusinessAction; engine-double-contract.pinned.json and data-engine.mdx, disjoint hunks; git merge-tree is clean and GitHub reports the head mergeable, clean), the head's check-runs (read 2026-10-31T02:37Z: check-runs on 1d4e697: 55 total, 42 success (Auto Label, Build & E2E UI, Build Core, Build Docs, Build UI Deps, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, Lint · CI, release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Live E2E UI, PR-body guards (claim · duplicate · Part-of · single-writer path), Plan Test Core shards, Spec property liveness, Test Core, Test Core (all 8), Test UI, Type Check UI, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter); 13 skipped path-filtered (18.x upgrade harness (opt-in), Auto Label, Check PR Size, Dogfood Regression Gate (matrix shard/3), Dogfood Verify CLI, Lint UI, Packed-tarball smoke (opt-in), Schedule gate · skip when a green push run covers the commit, Temporal Conformance (live PG + MySQL), Test UI (matrix shard/16), UI Published Artifacts); 0 failure, 0 in progress). Author user.login is objectstack-fleet[bot]; the drafter is session_017ihL2fi9zfJhUTubyJYb6V on claude/issue-27908-action-audit-surface; the reviewer is not the drafter.

① Derived judgments

  • The key: ExecutionContext.surface, z.enum(['rest', 'mcp']).optional(), with the performedBy discipline in its docblock and describe (server-constructed, attribution only, never a subject, scope or gate). @objectstack/core adds it to the closed non-entry set, so the closed-field pin stays green and identity resolution does not produce it. Absence stays absent; nothing in the permission or RLS path reads it (checked by name across the diff: only the two doors write it and only the audit writer reads it). RIGHT.
  • The doors: each of the three post-admission dispatches is split into its own function with the code unchanged, and the door writes one event after it, on a copy of the envelope stamped from the door's own identity (withActionSurface), so the request's own envelope, which the gates and the dispatch read, is left as built. REST reads the answered status and error code; run_action and resume_run catch the thrown refusal, record it through resolveThrownHttpError, and rethrow. A call an admission gate refused writes no row; a call that reached its branch writes exactly one, whether it answered or refused. The forgery cases are pinned on both doors (a forged forward mark, a surface header and an upstream surface value all leave the row naming the true door). recordActionInvocation resolves the audit slot per call and never throws, so a host without the audit plugin, or a sink that throws, cannot change an action's answer. RIGHT.
  • The row: action: 'action_invocation' joins sys_audit_log.action writer-first with a label and four generated bundle entries; user_id, actor (by the CRUD mirror's rule, only when the deployed ledger has the column), tenant_id from the envelope; object_name plus record_id for a subject record so the record read gate judges it, neither for an object-less action; no snapshots and no params (a param can carry a credential); metadata carries surface (copied, never inferred: an envelope naming none records null), action_name, branch, outcome, a failure's status and error_code, a resume's run_id, and the performed_by / on_behalf_of pair when an agent performed the call. A lost row is reported once per cause at error through the named persistActionInvocationAuditRow, registered in DURABILITY_CRITICAL_CALLEES and WRITE_SHAPED_CALLEES, so the durability gates anchor on it. Read back through a real engine in the plugin-audit test. RIGHT.
  • Read side and census: the visibility doc block places a row with a subject record under the record gate and a row without one under the ledger's own grant; the redaction doc block records that the row writes no snapshot; the tenant-audit census and its counts page gain the one new write site (elevated, isSystem). The engine-double ledger pins the test's one update double. RIGHT.
  • The forward: the api branch's row is written by the door after the in-process forward answers, from the door's own envelope, so it names the true door with no header on the forward (pinned). See ④ for the one shape this leaves open.

② Semver level

@objectstack/spec, @objectstack/core, @objectstack/runtime, @objectstack/plugin-audit: all minor, with Clause-②: yes (widening) and Metadata-protocol: added on the changeset, the PR body and the claim (the claim said none; the dev measured that the key moves authorable-surface/kernel.json and the changeset gate refuses none when a shard moves, and the seat accepted the amendment). A new optional key on a published schema, a new service-slot member, a new ledger value and a new row writer are additive; no removal, no narrowed value, no BREAKING banner, no ADR-0087 disposition owed. The level matches the diff.

③ Boundary flags

  • Name approval owed to the maintainer: ExecutionContext.surface with the closed values rest and mcp, and the new sys_audit_log.action value action_invocation. A rename would not move this verdict.
  • Serial note: main has moved 462 files since 9f13ee8; the three shared files merge clean and the hunks do not touch each other. Main's new identity-route key refusal in forwardApiAction fires inside the api branch after admission, so under this PR it records a failed api row, which is the right reading of it.
  • Out of scope, filed by the seat: an OAuth-token agent's api action forward may reach its target as anonymous (bearer honoured on /mcp paths only). Read from code, reach not measured; not this card.
  • Not governed, below 3000 lines: Tier S once the maintainer's word on the names is on record.

④ Design judgment

Scenario: an administrator reading sys_audit_log tells an action an agent ran over MCP from one a person clicked, on every branch an action can take, without a second log. Judged against ruling 6513869839 parameter 3, ADR-0090 D10 rule 4 (the performedBy precedent), ADR-0049 / ADR-0078 (a declared key must be read), the #5226 durability class, and triage 6514607572's route (a surface key on the context, the writer in plugin-audit, the forward headers in runtime).

  • execution-context.zod.ts, surface: a fact about the door, not the credential, so it is set for every principal on /mcp (a human's API key included), which is what tells the two rows apart when performedBy is absent. Read by the audit writer from the first commit, so it is live, not planned. Holds.
  • The door-side row instead of a forward header (the dev's deviation 1, the seat's acceptance, the triage route's "forward headers"): the measurement is right. The forward mark carries no trust, a header honoured like it would let an agent's call record rest, and the invocation row does not need the carry because the door writes it from its own envelope after the forward answers. The ruling's row is delivered on every branch, including api. Holds.
  • S1 (should-fix, not blocking; a follow-up card or this PR): the one shape the door-side row leaves open is an api action whose target is another action route on this server. The forward goes through the dispatcher, so the inner REST door writes its own rest row for an invocation an agent started; the outer mcp row exists with the same caller and instant but its metadata names only action_name and branch: api, not the target, so the two rows are joined by time alone. Two cheap, trust-free improvements: carry the api target path in the outer row's metadata, and have the inner REST door record the forward mark's value it already reads for the loop refusal as forwarded_from in its metadata (a hint, not a surface: a forged mark would mislabel the hint and nothing else). The inner row's surface: rest stays true, since the server did make a REST request to itself.
  • S2 (should-fix, not blocking): recordActionInvocation is awaited on the answer path of every action call, so one ledger insert sits in front of each action's response; the auth-event sink is awaited the same way, so this is the house pattern, but the row could be written after the answer is returned (fire-and-report) if action latency ever becomes a measured complaint.
  • Tried and did not break: every branch on both doors, success and post-dispatch refusal; the subject-record 404 as a post-admission refusal; the D4 gate as a no-row refusal; the forged mark, header and upstream value on both doors; the envelope copy; the agent's performedBy beside the surface; resume_run success and refusal with run_id; a lost row reported once and the writer never throwing; an older ledger without actor; an envelope with no surface recording null; the action option inventory and the retired-actions writer inventory.

Verdict on ④: no must-fix; two should-fixes.

Implemented-by: claude/issue-27908-action-audit-surface
Reviewed-by: session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

Seat note on PASS 6537892848 · 2026-10-31T02:44Z · head 1d4e697f863b87ad87999502f43e306c3095a2bb.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

Landing stroke · 2026-10-31T05:07Z · head 1d4e697f863b87ad87999502f43e306c3095a2bb.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 05:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

1 participant