Skip to content

fix(security)!: a hook that runs as system and an action body write no identity assignment - #25954

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-hg746g
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-hg746g

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Part of #25653

Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue)
Metadata-protocol: none

The run-time half of the card. The save-time half (refusing at registration a hook or action body that names one of the four objects under system elevation, with its ADR-0087 D3 semantic entry) is domain:spec work, pointed to on #6017; the card stays open for it.

What changed

The #25251 refusal reads isSystem AND flowRunId. Two other app-authored elevations carry isSystem and no run, so it did not see them: a hook's runAs: 'system' api, and an action body's ctx.api / ctx.engine. Each is now derived with a provenance mark, and the one predicate reads it.

Context Governed write to sys_user_position, sys_user_permission_set, sys_invitation, sys_member Reads of the four Writes to other objects
runAs: 'system' hook (sandboxed body or in-process handler) refused, PERMISSION_DENIED (was: admitted past every gate) admitted admitted
action body, ctx.api or ctx.engine refused, PERMISSION_DENIED (was: admitted past every gate) admitted admitted
runAs: 'system' flow refused, word for word as before admitted admitted
runAs: 'user' hook not judged here; the ordinary gates judge the user it acts for per the user per the user
platform sudo(), bare { isSystem: true } writers admitted, unchanged (no mark) admitted admitted
  • @objectstack/core, new security/app-automation-elevation.ts: the mark is a registry symbol (Symbol.for, so the ESM and CJS builds agree) with markAppAutomationElevation, appAutomationElevationOf and carryAppAutomationElevation. A symbol cannot be spelled by a request body or a stored row, so no caller can claim or shed it. It lives in core because it is the one package objectql, runtime and plugin-security all depend on (the reasoning operation-private-keys.ts gives).
  • objectql ScopedContext.withRunAs('system') builds the sudo()-shaped context plus the 'hook' mark. sudo() itself is untouched.
  • runtime buildActionExecutionContext adds the 'action' mark; buildActionApi's malformed-envelope fallback now goes through the same builder.
  • objectql createContext carries the mark across ExecutionContextSchema.parse. The parse keeps declared keys only, and an action body's ctx.api is built through it, so without the carry the body's ctx.api writes reached the refusal unmarked (measured: ctx.engine was refused and ctx.api was not).
  • plugin-security flow-system-identity-write-refusal.ts: the predicate is now isSystem AND (flowRunId OR the mark), in a new elevationSource helper. A context carrying both a run and a mark is answered as the flow, so a system flow's refusal is byte-identical. Each source names itself and its fix: a hook sets runAs: 'user'; an action body has no runAs, so its grant moves to a runAs: 'user' flow, which an action of type flow runs as the invoking user; invitations and memberships use the organization endpoints. No second module, no new error code.
  • content/docs/permissions/system-context.mdx row 1b covers the family; its anchor moves to #elevationSource, where the isSystem read now sits (check-system-context-census exit 0).
  • Comment only: the middleware's call-site comment in security-plugin-authz-middleware.ts.
  • Changeset: the four packages patch (Metadata-protocol: none), BREAKING banner, ADR-0087 not-required (no-migration-prescription).

Measured before any code (card condition 1)

On main cb0a2fcf, real ObjectQL + SQL driver + SecurityPlugin + QuickJS, a plain member as the trigger or caller:

  • a runAs: 'system' hook, as an L2 body and as an in-process handler, wrote each of the four objects (positions and grants with granted_by null);
  • an L2 action body wrote each of the four objects;
  • an L1 expression action body cannot reach ctx at all, so it writes nothing. Per condition 1 that sub-path is reported as a fork, not fixed;
  • control: the same member's direct writes are refused PERMISSION_DENIED.

Census: no hook, action body or host action handler in objectstack (packages/, examples/, apps/) or HotCRM writes the four objects; the writers on main are platform code with their own system contexts. Nothing shipped starts being refused.

Tests

  • New runtime/src/app-automation-identity-write-refusal.integration.test.ts (50 cases), real engine, driver, security middleware and QuickJS, store read back past the middleware:
    • runAs: 'system' hook, body and handler × four objects × insert/update/delete: refused with code, status, source and prescription; the store does not move and the triggering write aborts;
    • action body ctx.api × four × three verbs, and ctx.engine per object: the same;
    • controls: the same hook writes another object; the same body reads the four and writes another object; the hook under runAs: 'user' is refused by the ordinary gates, not by this refusal; a sudo() derived from a user context and a platform system context insert, update and delete all four.
    • Red first (commit 7bb83a96, before the fix): 40 refusal cases failed, 10 controls passed.
  • plugin-security refusal test: the predicate for both marks over every GOVERNED_OPERATIONS verb on each object, run-plus-mark answered as the flow, the mark without isSystem not judged, an unknown mark value read as none. PR1's cases unchanged and green.
  • core app-automation-elevation.test.ts (6): symbol identity, fresh copy, spread survival, JSON cannot spell it, dropped by the parse and carried across it.
  • runtime/action-body-identity.test.ts: two toEqual({ isSystem: true }) pins now include the mark, plus one case that the builder carries it.

Pins describe behaviour only.

Verification

All at head a8b13e57, under the verify lock:

  • pnpm test per package, each VERDICT command-exit 0: core 119 files / 2763 passed; plugin-security 223 files / 4683 passed, 45 skipped; objectql 467 files / 8718 passed; runtime 445 files / 7151 passed, 19 skipped.
  • pnpm typecheck in the same four packages: exit 0 each, including check:test-typecheck where the package has it.
  • eslint --no-inline-config on the 10 changed TypeScript files: exit 0.
  • Derived gates (dispatch-gates --commands, 108 families): 108 exit 0, reconciled with --ran (0 NOT-MEASURED, 0 UNRUN). Four answered a prerequisite on the first pass and exit 0 after it was met: check-engine-split-ratio (shallow clone, deepened with --shallow-since), and check:skill-examples, check:dual-build-cjs-loads, check:i18n (full turbo run build).
  • Ablation (scripts/ablation-replace.mjs, one behaviour: the predicate's read of the mark, elevationSource), with the marker proven in dist/ by ablation-dist-preflight before the readings, because the runtime suite reads plugin-security from dist/:
    • runtime pin: 40 failed, 10 passed. The 40 are exactly the hook and action refusal cases; every control stays green.
    • plugin-security pin: 48 failed, 47 passed. The 48 are the two marks × four objects × six verbs; PR1's flow cases and the controls stay green.
    • Restore: blob equals HEAD, git diff HEAD empty, rebuilt, marker absent from dist/, both pins green again (50/50, 95/95).
    • A first attempt was a no-op: its marker was a comment, which the build strips, so the preflight refused it before any reading. The second used a marker that survives the build.
  • Full dogfood battery (owed for the (narrowing) arm): running at head a8b13e57 when this PR was opened; its counts go in the os-dev-report on Security: a runAs system hook or an action script body reaches the four identity-assignment objects past the delegated-administration gate (the #25251 refusal requires flowRunId) #25653.

Acceptance notes

  • Claimed surface, declared departures: packages/core/src/security/ (new module, barrel export, test), objectql createContext (the carry, beside the claimed withRunAs), the middleware call-site comment, and the two pin updates in action-body-identity.test.ts. No packages/spec/**, and sudo() is unchanged.
  • buildActionExecutionContext serves every handler an action door dispatches, host code handlers included, so a host handler's writes to the four objects are refused too. The census found none.
  • ObjectRepository.execute() (the third executeAction caller) elevates with { ...context, isSystem: true } and is not one of the two ruled derivation sites, so an action reached that way from an unmarked context runs unmarked. The sandbox does not expose execute, so only host code reaches it. Noted, not changed.
  • The mark is an undeclared, in-process context key rather than an ExecutionContext field, because this PR may not touch packages/spec. Declaring it there is an option for the spec half; see the report on Security: a runAs system hook or an action script body reaches the four identity-assignment objects past the delegated-administration gate (the #25251 refusal requires flowRunId) #25653.
  • Branch is the harness-assigned claude/project-thread-hg746g; the claim comment is the identity record.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k

…refused writes to the four identity objects

Red on main: the 40 refusal pins fail (the writes land), the 10 controls pass.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
…ites to the four identity objects

The #25251 refusal read isSystem AND flowRunId, so two other app-authored
elevations — a hook's runAs:'system' api and an action body's context —
reached the identity-assignment objects past the D12 gate and the
organization doors. Each is now derived with the app-automation elevation
mark (@objectstack/core): ScopedContext.withRunAs('system') and
buildActionExecutionContext set it, ObjectQL.createContext carries it across
its parse, and the one predicate reads it beside flowRunId. sudo() never sets
it, so platform writes are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, @objectstack/plugin-security, @objectstack/runtime, touching 18 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-handling-server.mdx (via ScopedContext (symbol, a top-level class))
  • content/docs/automation/hook-bodies.mdx (via ScopedContext (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via ScopedContext (symbol, a top-level class), elevationSource (symbol, a top-level function), registerAuthzMiddleware (symbol, a top-level function))
  • content/docs/protocol/kernel/i18n-standard.mdx (via createContext (symbol, a method of class ObjectQL))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 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 — 57 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 2a728ba2acf52c13d1918c2afca7b79bf391e1f5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c45bddd47078b11b6ce388f3148aa66e30cd12bb — the merge of head a8b13e57297024c1c0592ce81ced7d1e295a8df5 into base 2a728ba2acf52c13d1918c2afca7b79bf391e1f5, 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 c45bddd47078b11b6ce388f3148aa66e30cd12bb && git checkout c45bddd47078b11b6ce388f3148aa66e30cd12bb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a728ba2acf52c13d1918c2afca7b79bf391e1f5 a8b13e57297024c1c0592ce81ced7d1e295a8df5 && git checkout -B drift-repro 2a728ba2acf52c13d1918c2afca7b79bf391e1f5 && git merge --no-ff a8b13e57297024c1c0592ce81ced7d1e295a8df5

node scripts/docs-audit/affected-docs.mjs --json 2a728ba2acf52c13d1918c2afca7b79bf391e1f5

⚠️ 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 2a728ba2acf52c13d1918c2afca7b79bf391e1f5 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a8b13e57297024c1c0592ce81ced7d1e295a8df5
Local-runs: none
Scope: packages/core/src/security/app-automation-elevation.ts, packages/core/src/security/app-automation-elevation.test.ts, packages/core/src/security/index.ts, packages/objectql/src/engine.ts, packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts, packages/plugins/plugin-security/src/flow-system-identity-write-refusal.test.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/runtime/src/action-execution.ts, packages/runtime/src/action-body-identity.test.ts, packages/runtime/src/app-automation-identity-write-refusal.integration.test.ts + packages/objectql/src/hook-run-as.ts, packages/objectql/src/hook-wrappers.ts, packages/runtime/src/sandbox/body-runner.ts, packages/runtime/src/sandbox/quickjs-runner.ts, packages/runtime/src/stored-metadata-reader-seam.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/core/src/security/operation-private-keys.ts, packages/core/src/index.ts, packages/spec/src/kernel/execution-context.zod.ts, scripts/check-adr-0087-registration.mjs, .github/workflows/lint.yml, .github/workflows/pr-automation.yml, packages/plugins/plugin-security/CHANGELOG.md; the prose faces (content/docs/permissions/system-context.mdx, .changeset/25653-app-automation-identity-write-refusal.md) read with them. No path is a remainder.

① Derived judgments

Read on the net diff origin/main...origin/pr-25954 (merge base cb0a2fcf, two commits: the test pin 7bb83a968e alone, then the fix a8b13e5729), the cited code on the head, card #25653 (body and all five comments), the PR body and the check-runs on the head.

CI state at review time. 21 check-runs concluded success (Check Changeset — the pr-automation changeset-check job that runs the real ADR-0087 and empty-changeset scans, Governed Surface Queue Guard, Lint · ESLint, Lint · docs & code guards, Lint · CI/release & engine gates, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Lint · scoped · query-options erasure, Type Check · source gates, Build Docs, Check Documentation Links, Spec property liveness, the three claim guards, Dogfood Regression Gate, Flag docs, Auto Label, Check PR Size, filter), 11 skipped (matrix stubs and opt-in harnesses), and 17 still in_progress: Build Core, Test Core 1–8, Type Check · workspace / consumer gates / debt ledger, Type Check UI, Build & E2E UI, Lint · repo & tooling gates, Lint · scoped · slot lookup & corpus, and a second Check Changeset run. So the derived test and typecheck families have NOT concluded on the head, and neither has check:system-context-census (it runs inside Lint · repo & tooling gates). This record judges the diff and the concluded gates; the dev's local counts (core 2763, plugin-security 4683, objectql 8718, runtime 7151 passed; census exit 0) are the dev's claim, not a gate verdict. The queue's own every-check-green condition stands apart from this verdict.

Accept-set change — what is now refused, and by which door. One door: the data middleware's single call assertFlowSystemIdentityWriteAllowed(opCtx) immediately ahead of the isSystem bypass (security-plugin-authz-middleware.ts, unchanged position; the diff there is comment-only). Its predicate moves from isSystem AND flowRunId to isSystem AND (flowRunId OR mark in {hook, action}) through the new elevationSource helper. Refused: the six GOVERNED_OPERATIONS verbs (insert, update, delete, transfer, restore, purge, read from delegated-admin-gate.ts) on exactly sys_user_position, sys_user_permission_set, sys_invitation, sys_member, from a context carrying isSystem plus the mark. Reads of the four and writes to every other object are untouched (pinned in both test files). RIGHT, and the ruling's shape: one predicate, one module, no new error code (condition 3).

Producers of the mark — exactly the two ruled derivations. (a) ScopedContext.withRunAs('system') now returns new ScopedContext(markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'), engine) instead of this.sudo(); the envelope is the same sudo() shape plus the mark. Both hook surfaces reach it: wrapDeclarativeHook (hook-wrappers.ts:548) installs the derived api into ctx.api before the handler runs, and the sandbox's buildSandboxApiSource takes engineCtx.api first, so an L2 body's ctx.api.object(...) is the marked api too; the engine's buildHookApi(opCtx.context) builds new ScopedContext(...) without a parse, so nothing between the derivation and the write strips it. (b) buildActionExecutionContext(ec) adds 'action'; its three consumers — buildActionApi (through ql.createContext, where the new carry preserves the mark), buildActionEngineFacade (passes the marked context straight into ql.insert/update/delete), and actionContext.executionContext (the sandbox's last-resort facade, also through createContext) — all carry it, and the malformed-envelope fallback now goes through the same builder instead of a bare { isSystem: true }. RIGHT (condition 2).

sudo() untouched; platform system writes still admitted. ScopedContext.sudo() (engine.ts:23381) is byte-identical to main and sets no mark; bare { isSystem: true } writers (jobExecutionContext, seeders, better-auth sync, approval mirrors, summary-backfill's SYSTEM_CTX) carry neither a run nor a mark and pass to the bypass unchanged. Pinned end to end: the CONTROL block inserts, updates and deletes all four objects through engine.createContext(PLAIN).sudo() and through a bare platform context, past the real middleware. RIGHT (condition 2's pin, condition 4's fourth pin).

The carry across the parse is necessary and sole. ExecutionContextSchema is a z.object of declared keys, so a symbol-keyed property does not survive .parse (pinned in core). On the head, ExecutionContextSchema.parse is called at exactly one place in objectql, runtime and plugin-security source — ObjectQL.createContext — and that is the one the diff wraps with carryAppAutomationElevation. The dev's measurement (ctx.engine refused, ctx.api not, before the carry) matches the code: the facade never parses, the api does. RIGHT. Residual: any FUTURE producer that rebuilds a context by parse or key-pick drops the mark fail-open; the dev names this in open question 1 (judged in ③).

Forgery and leakage from app code. The key is Symbol.for('objectstack.core.appAutomationElevation'). An L2 body gets no raw context and no sudo/withRunAs/execute on its bridge: quickjs-runner.ts installs only object(name) with the fixed READ and WRITE method lists plus the transaction leaves, and arguments cross as JSON, which cannot spell a symbol key (pinned in core). appAutomationElevationOf admits only the two literal values, so a forged other value reads as no mark (pinned in plugin-security) — and a forged mark only ever NARROWS, since the sole reader refuses and admits nothing. Shedding: a marked api's sudo(), transaction callback context, cascade legs ({ ...execCtx, isSystem: true }) and reference-check contexts all SPREAD the caller, and object spread copies own enumerable symbol keys (pinned "rides a spread"), so a write set off under a marked context stays judged as the automation's, exactly as flowRunId rides. The one copy that would drop a symbol, withoutOperationPrivateKeys (Object.entries), has eleven callers on the head; the two in the engine (callerMayReadSysFile, the field-owned sys_file read) issue READS of sys_file only, and the plugin-security callers sit below the bypass a marked system context never reaches, so no write to the four objects is re-issued under a stripped copy. JSON.stringify and structuredClone skip symbol keys, so the mark cannot land in an audit row or a stored context. RIGHT.

Byte-identity of the flow refusal. Composed from the diff, SUBJECT.flow + " is refused — " + "position holdings, ... governed identity writes, " + "and " + ELEVATION.flow + " does not lift that. " + ASSIGNMENT_PRESCRIPTION.flow reproduces PR1's string character for character, MEMBERSHIP_PRESCRIPTION is unchanged, and elevationSource answers 'flow' whenever flowRunId is set regardless of a mark, so a runAs: 'system' hook fired inside a system flow's write is refused in the flow's words. Pinned: PR1's cases reuse the untouched expectFlowSystemRefusal, and the run-plus-mark case asserts through it. RIGHT (condition 4's third pin).

Per-source prescriptions. Hook: set runAs: 'user'. Action body: it has no runAs, so the grant moves to a runAs: 'user' flow, which an action of type flow runs as the invoking user. Memberships and invitations: the organization endpoints. Each is a real door on the head (hook-run-as.ts for the hook arm; the flow-door runAs: 'user' path for actions). RIGHT; no tracker number in runtime prose.

The runAs: 'user' hook under this refusal. withRunAs('user') yields { ...ctx, isSystem: false }; a mark that rides in from an action-body-triggered write is then inert because the predicate requires isSystem (pinned "mark without isSystem"). The ordinary gates judge the user (pinned: a plain member's insert is refused with no "runs as system" wording). RIGHT (condition 4's second pin).

Each path on each of the four objects, with code and prescription. The integration file drives hook-body, hook-handler and action-body × four objects × insert/update/delete plus ctx.engine per object, reading the store back past the middleware and checking the triggering write aborted; transfer/restore/purge have no engine verb and are covered at the predicate in plugin-security (both marks × four × six). The pin was committed red-first (7bb83a968e carries only the test file). RIGHT (condition 4's first pin, condition 1's measure-first).

Unmarked system routes that remain, by the ruling's own placement. Read and confirmed, none a defect of this diff: (a) ObjectRepository.execute() (engine.ts:23340) elevates { ...this.context, isSystem: true } — from a MARKED api the spread carries the mark, so a body composing a sibling action stays judged; from an unmarked host ScopedContext it runs unmarked, and the bridge exposes no execute, so only host code reaches it; (b) a hook with no runAs ('inherit') fired by a platform system write inherits that unmarked system context (the dev's out-of-scope finding, see ③); (c) an in-process hook handler — host TypeScript, not a declared body — under runAs: 'user' or 'inherit' can call ctx.api.sudo(), which the ruling says is never marked. All three are the ruling's shape (mark only at the two app-authored derivations), not gaps this PR could close under its remit.

Public surface. @objectstack/core gains APP_AUTOMATION_ELEVATION, markAppAutomationElevation, appAutomationElevationOf, carryAppAutomationElevation and the AppAutomationElevation type, exported through security/index.ts and the root barrel; buildActionExecutionContext's return now carries a symbol key (two toEqual pins updated accordingly); FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS keeps its name and members. Host-code action handlers dispatched by an action door are also refused, because they share the builder — a deliberate consequence the PR names, with a census of none. RIGHT.

Docs census row. Row 1b of system-context.mdx now describes the family and anchors at #elevationSource, where the isSystem read sits; no other isSystem read is added (appAutomationElevationOf reads only the symbol; the producers set, not read). The anchor resolves to a declaration in the file. RIGHT in reading; the gate that confirms it is in the in-progress job named above.

Nothing shipped starts being refused. On the head, every file in packages/, examples/, apps/ that declares runAs: 'system' and names one of the four objects does so in prose or registries only; in the pinned HotCRM checkout, the three runAs: 'system' sites read sys_user_position (.find) and write none of the four. The dev's census holds.

② Semver level

  • The changeset .changeset/25653-app-automation-identity-write-refusal.md bumps @objectstack/plugin-security, @objectstack/objectql, @objectstack/runtime, @objectstack/core at patch — exactly the four packages the diff moves outside tests and prose.
  • Its Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue) line is byte-identical to the PR body's; Metadata-protocol: none is on both. The arm is right: a served-set narrowing (a write once admitted is now refused), BREAKING by rule; none is right because nothing an author can write changes at save time in this PR — the save-time refusal and its ADR-0087 D3 semantic entry are the spec half the card routes to domain:spec (condition 5), so the semantic kind belongs to that PR, as PR1's own semantic entry (18.flow-run-as-system-identity-assignment-write-refused.ts) shows for flows.
  • Level: by AGENTS.md the level follows the kind alone — none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". The banner is present and names the audience; the disposition not-required (no-migration-prescription) is in the gate's closed vocabulary and the body removes or renames no authorable key, so there is nothing for objectstack migrate meta to rewrite; the "What changes for you" paragraph is the ruling's fix, not a migration prescription, and the pr-automation changeset-check run that scans it concluded success. PR1 shipped minor under 18.1.0 because it carried the semantic kind; the difference here is the kind, not an inconsistency.
  • Noted, not a fault: core's four new exports would be minor under generic semver; the repo's rule ties the level to the metadata kind, and that rule governs here. The face: the paths decide none, the changeset's (narrowing) arm decides, so the review is owed at CONTRACT_REVIEW_TIER on the narrowing face; no governed-surface path is in the file list (the queue guard concluded success).

③ Boundary flags

Dev report: comment 6408799371 on #25653; status: done, premise_still_valid: true.

  1. Open question (non_blocking): symbol in core vs a declared ExecutionContext field. Answered: option A is right for this PR. The only reader narrows, the one parse on the path is carried and pinned, and a symbol is the one spelling no request or stored row can forge. The honest cost — a future producer that rebuilds a context by parse or key-pick drops the mark fail-open, caught only by the end-to-end pins — is real and belongs with the spec half, where option B (a server-constructed declared key beside flowRunId) can be weighed against "no declared surface without a puller". Carried: the PR body and the report say a pointer was left on [PM seat] domain:spec — 🟢 os-support-ai · 由 os-support-ai 账户的项目负责 #6017 (comment 6408782475); that comment is outside this review's inputs and is taken as the dev's claim.
  2. Fork, card condition 1: the L1 expression action body writes nothing (no api reaches it). Reported on the card in the report, as condition 1 requires ("reported as a fork on this card, not fixed"). Answered; nothing further owed here.
  3. Out-of-scope finding, class a, security: a hook that declares no runAs inherits a platform system write's context and reaches the four objects unmarked. Confirmed by reading (deriveHookApi returns the same api for 'inherit'; buildHookApi(opCtx.context) wraps the triggering context as is). It is outside this PR by the ruling's placement of the mark, so it does not block. ESCALATED: "noted, not filed" with the seat as carrier is not a resting place for a security-class gap — whether an inherited elevation counts as app-authored automation is a ruling question, so the seat is to file it as its own card (mechanism only, per the disclosure line) or put it to the maintainer, before the card closes. The same card should carry the ObjectRepository.execute() third-caller note (finding 3) and the in-process ctx.api.sudo() route named in ①.
  4. Out-of-scope finding 4: host-code action handlers are refused too. Confirmed (the door's builder is shared). Accepted: the ruled site is the action body's execution-context builder, the census found no host handler writing the four, and the PR body's Acceptance notes name it. Non-blocking disclosure gap: the changeset's audience line names "an action body (type: 'script')" and not a host handler dispatched by an action door; a one-clause addition in the shipped changelog would close it, and it may ride the spec half or a follow-up.
  5. Remainder named and carried. The PR is Part of #25653 (the Part-of guard concluded success), the card stays open for the save-time refusal and its D3 semantic entry in domain:spec, and the changeset's none kind defers the semantic entry to that PR, as the card rules. Consistent.
  6. Gates not yet concluded on the head. Build Core, Test Core 1–8, the three Type Check jobs, Type Check UI, Build & E2E UI, Lint · repo & tooling gates (which holds check:system-context-census), Lint · scoped · slot lookup & corpus, and a second Check Changeset were in_progress when this record was rendered. This verdict is on the diff and the concluded gates; the owning seat does not queue until every check is green, and a red in any of those families reopens this record on the same head.
  7. Dev flags honoured. Claimed surface and the declared departures (core module, createContext carry, middleware comment, two pin updates) match the diff file for file; no packages/spec/** touched; sudo() unchanged; no reproduction steps on the card or PR text (the test file is the ruled pin, not a disclosure); the first ablation's no-op was declared and rerun with a build-surviving marker.

Implemented-by: claude/project-thread-hg746g
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T05:30Z

Head a8b13e5729, report 6408799371, claim 6407781630, contract review 6408962867 (PASS, narrowing face).

item reading
Shape draft, base main, Part of #25653 (the card stays open for the save-time half in domain:spec; pointer on #6017 6408782475), Clause-②: no (narrowing: …) and Metadata-protocol: none line-initial in body and changeset, four patch bumps with the BREAKING banner.
Files (12, +738/−24) the core mark module + barrel, the objectql createContext carry, the predicate in flow-system-identity-write-refusal.ts, a comment in the authz middleware, the action execution-context builder, tests, one docs line, one changeset: the claim's surface plus the departures the PR body declares. No packages/spec/**. Not governed (check-governed-merges --pr 25954), 762 changed lines.
Fix the one predicate becomes isSystem AND (flowRunId OR mark); the mark is set only at withRunAs('system') and buildActionExecutionContext, so app-authored hook and action-body writes to the four identity objects are refused the way #25251 refuses flows; sudo() and platform system writes unchanged; the flow refusal string is unchanged.
Contract review PASS on this head. Option A (symbol) accepted for this PR, option B carried to the spec half. The L1 expression fork is reported on the card. The security-class out-of-scope finding is escalated to the seat, which carries it as its own card; it does not block this PR.
Evidence red first 40/50 on main; ablation 40 + 48 pins red then restored; 108/108 derived gates exit 0; dogfood 296 files / 2523 tests green (report).
Landing ready + auto-merge through the relay once every check on this head is green or an expected skip. The card keeps pm:dispatched (Part of).

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 05:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 26, 2026 05:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 073f08b Oct 26, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-hg746g branch October 26, 2026 06:10
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants