Skip to content

feat(plugin-security)!: a flow that runs as system writes no identity assignment - #25483

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-t123rb
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-t123rb

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Part of #25251

Clause-②: no (narrowing)
Metadata-protocol: semantic

This is PR1 of ruling 6354966423 (letter B): the run-time arm, in plugin-security. PR2, the runAs describe sentence and the ADR-0087 D3 semantic entry in packages/spec, belongs to the spec lane and is handed to its seat; the card stays open for it.

What changed

The data middleware refuses one system context ahead of its isSystem bypass: a context that carries isSystem and a flowRunId, which is what a runAs: 'system' flow's data nodes write under (resolveRunDataContext, service-automation). Its governed writes to the four identity-assignment objects are refused with PERMISSION_DENIED (403) and nothing is written.

Context Insert, update, delete, transfer, restore or purge on sys_user_position, sys_user_permission_set, sys_invitation, sys_member Reads of the four Writes to any other object
isSystem + flowRunId (runAs: 'system' flow, user-triggered or scheduled) refused, PERMISSION_DENIED (was: admitted past every gate) admitted admitted
isSystem, no flowRunId (seeders, better-auth and its reconciliation, invitation placement, the organization-administrator grant) admitted, unchanged admitted admitted
flowRunId, no isSystem (runAs: 'user' flow) not judged here; the ADR-0090 D12 gate judges the user the run acts for, unchanged per the user per the user
  • New module flow-system-identity-write-refusal.ts: assertFlowSystemIdentityWriteAllowed(opCtx) and the object set. The predicate is the ruling's, isSystem AND flowRunId, read by truthiness as the bypass reads isSystem. The verbs are the D12 gate's own GOVERNED_OPERATIONS, now exported from delegated-admin-gate.ts so that a verb added there is refused here too.
  • The prescription follows the ruling. For the two assignment tables it is to set the flow's runAs to 'user', so the gate judges the acting user and records them as granted_by. For invitations and memberships it is the organization endpoints (invite-member, cancel-invitation, update-member-role, remove-member), which apply the invitation's placement and role-cap checks.
  • security-plugin.ts: one import and one call in the data middleware, immediately before // System operations bypass security. Nothing else in the file moves.
  • Comments only: the delegated-admin-gate.ts header and GRANTED_BY_OBJECTS docblock, which said every system write short-circuits before the gate, and the resolveRunDataContext docblock sentence in service-automation that said a system run "reads/writes with full access".
  • content/docs/permissions/system-context.mdx gains row 1b, because check-system-context-census holds every isSystem read site to a row on the page.
  • Changeset: @objectstack/plugin-security minor, with the BREAKING banner (FROM / TO / Unchanged / Fix) and the ADR-0087 not-required (no-migration-prescription) marker. The semantic entry itself is PR2's.

Census (taken before any edit)

create_record, update_record, delete_record and upsert_record node literals in objectstack at b2c075290 and hotcrm at c14ffde5f (TS, JS, JSON, YAML, MD, MDX; node_modules and dist excluded): 317 nodes, 0 aimed at any of the four objects. 78 carry no literal target near the node literal; each was read by hand and none names the four. The count is zero, so nothing is worked around.

Tests

  • plugin-security/src/flow-system-identity-write-refusal.test.ts (44 cases). Most drive the real SecurityPlugin middleware over a real ObjectQL engine and a real SQL driver, and read the stored rows back:
    • The predicate, directly, for every verb in GOVERNED_OPERATIONS on each of the four objects. transfer, restore and purge have no engine verb to drive.
    • Through the middleware: insert, update and delete from a user-triggered and a user-less flow system context on each object are refused with the envelope (code, status, details) and the store does not move.
    • Seeder control: { isSystem: true } inserts, updates and deletes each object.
    • Reads of the four, and writes to an ordinary object and to sys_business_unit, under the flow's system context.
    • A runAs: 'user' context: a tenant-level administrator's grant is admitted and stores granted_by = that user. A plain member's is refused by the D12 gate (its own message, details.userId = the member), not by this refusal.
  • service-automation/src/runas-system-identity-write-refusal.integration.test.ts (9 cases): real kernel, ObjectQLPlugin, driver-sql, the identity objects, the real SecurityPlugin and the real AutomationEngine CRUD nodes.
    • A runAs: 'system' flow's create_record on each of the four objects, user-triggered and scheduled, fails with innerRefusal.code PERMISSION_DENIED and no row is written. Its update_record and delete_record on a grant and on a membership are refused too, and the rows do not move.
    • A runAs: 'user' flow triggered by a tenant-level administrator grants the set, with granted_by = that administrator. Triggered by a plain member, the D12 gate refuses it.
    • Controls: a platform writer without a run writes all four objects. A runAs: 'system' flow still reads the four and still writes an ordinary object.

Pins describe behaviour only.

Verification

  • pnpm --filter @objectstack/plugin-security --filter @objectstack/service-automation test (under the verify lock, VERDICT command-exit 0): plugin-security 215 files, 4509 passed and 45 skipped; service-automation 199 files, 2628 passed.
  • pnpm typecheck in both packages: exit 0, including check:test-typecheck (both test layers compile, 0 pinned signatures).
  • eslint --no-inline-config on the six changed TypeScript files: exit 0.
  • Ablation (scripts/ablation-replace.mjs, one behaviour): the middleware's call was disabled behind a planted marker, which was proven in the source (anchor 1 → 0) and in dist/ (ablation-dist-preflight, 2 built files) before the service-automation leg ran, because that suite reads plugin-security from dist/.
    • plugin-security pin: 8 failed, 36 passed. The 8 are the through-the-middleware refusals (four objects, user-triggered and user-less). The predicate block, the seeder controls, the read and other-object controls and the runAs: 'user' cases stay green, as expected.
    • service-automation pin: 5 failed, 4 passed. The 5 are the four runAs: 'system' creates and the update and delete case. The runAs: 'user' cases and both controls stay green.
    • Restore: blob equals HEAD, git diff HEAD empty, rebuilt, marker absent from all 6 built files, and both pins green again (44/44, 9/9).
    • A first ablation run also turned the platform-writer control red, as a knock-on: the ablated system flow had written the same membership row the control then inserted. The control now writes its own non-member, and the second run above is clean.
  • Local gates (dispatch-gates --commands, 70 families): on the first pass 67 exited 0. The other three:
    • check-system-context-census: fixed. The new module reads isSystem and had no row; row 1b is added and the gate exits 0.
    • check-plugin-teardown-shape --self-test: exit 3, because the clone was shallow. Deepened with git fetch --unshallow origin main.
    • check:dual-build-cjs-loads: exit 3, because 7 prerequisite dists were missing. They are now built.
  • The final-head re-run of the gate union and the full dogfood battery (owed for the (narrowing) arm and the !) are running now. Their counts go in the os-dev-report on Security: a runAs system flow writes identity-assignment rows (sys_user_position, sys_invitation) past the delegated-administration gate #25251.

Acceptance notes

  • The branch is the harness-assigned claude/project-thread-t123rb, not claude/issue-25251-…. The claim comment is the identity record.
  • File surface, two declared departures from the claim. delegated-admin-gate.ts was claimed as comment only; it also gains export on GOVERNED_OPERATIONS (and a ReadonlySet type), so that both checks read one verb list. The package index is unchanged. content/docs/permissions/system-context.mdx was out of the claimed surface; the census gate requires the row.
  • The refusal reads the operation as the engine hands it to the middleware, so it applies at any depth under the flow's context: a cascade or set_null leg of a system flow's delete of a parent row, and a hook body's ctx.api write, are refused the same way. The census found no system flow that deletes a parent of the four objects. Exempting nested writes would let a hook body reopen what the ruling closes.
  • Not changed here: ADR-0073's sentence that a runAs: 'system' flow "stays god-mode throughout". docs/adr is outside this card's surface, and the precedent run-time PR (fix(service-automation): flow write nodes refuse a stored-metadata family target (#21624) #21649) did not amend its ADR either. The spec lane's PR2 or a separate ADR edit can carry it.
  • PR refactor(plugin-security): move the read filters, field readers and object admissions out of security-plugin.ts (#25223 cut 9) #25456 (draft) edits security-plugin.ts and the same docs page; its hunks in security-plugin.ts are elsewhere. Whichever lands second merges main.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu

… assignment

The data middleware refuses a governed write to sys_user_position,
sys_user_permission_set, sys_invitation or sys_member from a context that
carries isSystem and a flowRunId, ahead of its isSystem bypass, with
PERMISSION_DENIED and the remedy (runAs 'user' for grants, the organization
endpoints for invitations and memberships). System writers without a run,
reads, other objects and runAs 'user' runs are unchanged; a runAs 'user' run
reaches the delegated-administration gate as the user it acts for.

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

check-system-context-census holds every elevation read site to a row on the
page; the new refusal reads isSystem, so it gains row 1b.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
@objectstack-fleet
objectstack-fleet Bot requested a review from os-sales October 23, 2026 15:42
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/service-automation, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/src/runtime-identity.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/approvals.mdx (via sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/data-modeling/objects.mdx (via sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/deployment/environment-variables.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/deployment/self-hosting.mdx (via sys_invitation (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/deployment/tenancy-modes.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), /api/v1/auth/organization (route, a path literal in MEMBERSHIP_PRESCRIPTION))
  • content/docs/permissions/administrator-guide.mdx (via sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/permissions/authentication.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), /api/v1/auth/organization (route, a path literal in MEMBERSHIP_PRESCRIPTION))
  • content/docs/permissions/authorization.mdx (via sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/permissions/delegated-administration.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/permissions/permission-sets.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/permissions/positions.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/permissions/sharing-rules.mdx (via sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/permissions/system-context.mdx (via assertFlowSystemIdentityWriteAllowed (symbol, a top-level function), sys_invitation (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))

⛔ 13 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v13.mdx (via sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v14.mdx (via sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v15.mdx (via sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v16.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v17/17-0.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v17/17-1.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v17/17-3.mdx (via sys_invitation (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v17/17-4.mdx (via sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v17/17-5.mdx (via sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v17/17-6.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v18/18-0.mdx (via sys_member (literal, a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))
  • content/docs/releases/v18/18-1.mdx (via sys_user_permission_set (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS), sys_user_position (literal, a string literal in ASSIGNMENT_OBJECTS; a string literal in FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/src/runtime-identity.ts) — pages documenting those are invisible to this run
  • 1 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 — 19 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 ea33919f56a9879f95a00d45418385ff7dbb974f → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 ea33919f56a9879f95a00d45418385ff7dbb974f → 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: 648d903049c0a2f64baa919581404c5dab393d3f
Local-runs: none
Scope: packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/flow-system-identity-write-refusal.test.ts, packages/services/service-automation/src/runtime-identity.ts, packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts + packages/plugins/plugin-security/src/index.ts, packages/plugins/plugin-security/package.json, packages/services/service-automation/src/engine.ts (resolveRunContext), packages/services/service-automation/src/builtin/crud-nodes.ts, packages/objectql/src/engine.ts (buildHookApi, sudo, withRunAs, cascadeDeleteRelations), packages/objectql/src/hook-run-as.ts, packages/objectql/src/hook-wrappers.ts, packages/runtime/src/action-execution.ts (buildActionExecutionContext), packages/core/src/security/assemble-execution-context.ts, packages/spec/src/kernel/execution-context.zod.ts (flowRunId), packages/plugins/plugin-auth/src/auth-route-ledger.ts, docs/adr/0087-metadata-protocol-upgrade-contract.md (the ratified launch-window section), scripts/check-adr-0087-registration.mjs, AGENTS.md step 3; the two prose faces (.changeset/25251-flow-system-identity-write-refusal.md, content/docs/permissions/system-context.mdx) were read sentence by sentence as well. Card #25251 read whole, rulings 6349680816 and 6354966423, claim 6368019403, report 6369397150, PR #25483 body and files, net diff, the 42 check-runs on the head (all success or skipped; Check Changeset, Lint and Repo Gates, Type Check workspace, Test Core 1 to 6, Build Core, Build Docs, Governed Surface Queue Guard, Lint PM dispatch-gates all success).

① Derived judgments

  1. Predicate. assertFlowSystemIdentityWriteAllowed refuses when context.isSystem and context.flowRunId are both truthy, the object is one of sys_user_position, sys_user_permission_set, sys_invitation, sys_member, and the operation is in the D12 gate's GOVERNED_OPERATIONS. That is ruling 6354966423's letter verbatim (context.isSystem && context.flowRunId, the four objects the maintainer did not strike, PERMISSION_DENIED with the runAs: 'user' and invitation-door prescription). RIGHT. Truthiness mirrors the bypass's own read of isSystem; an empty flowRunId is never produced (engine.ts resolveRunContext stamps flowRunId only from a non-empty run id), and flowRunId is a server-only key (NonEntryExecutionContextField in core's assemble-execution-context.ts), so no client can supply or strip it.
  2. Reach on the four objects from a runAs: 'system' run, read in the cited code: every CRUD node (find, findOne, insert, update, delete in crud-nodes.ts) passes resolveRunDataContext(context), which carries flowRunId; a hook a flow's write fires gets buildHookApi(opCtx.context), so inherit keeps both keys and runAs: 'system' goes through sudo() which spreads the triggering context and adds isSystem, both refused; a cascade or set_null leg runs cascadeDeleteRelations(object, id, writeContext) under the parent's context, refused. No automation node invokes an action body, so the buildActionExecutionContext envelope is not reachable from a run today. No bypass found on the four objects. RIGHT. One consequence of the provenance-keyed predicate is named in ③ (the hook asymmetry); it is the letter's own.
  3. Placement. In the head's data middleware the order is: nestedInOperation read, seam handles, the #10757 epoch bump, assertFlowSystemIdentityWriteAllowed(opCtx), then if (opCtx.context?.isSystem) return next(). Nothing returns before the refusal; the thrown PermissionDeniedError never reaches next(), so no write path, hook or post-next assertion runs. RIGHT.
  4. Verb parity. GOVERNED_OPERATIONS is now the one list for both the D12 gate and the refusal; a verb outside it (none of the engine's write verbs is, and transfer, restore, purge are the middleware's own spellings at security-plugin.ts lines 3085, 3164, 3357) is equally outside the gate the refusal fronts, so the refusal is never narrower than the gate. RIGHT.
  5. Public surface. GOVERNED_OPERATIONS gains export and a ReadonlySet type in delegated-admin-gate.ts; index.ts re-exports only DelegatedAdminGate, isTenantAdmin and the DelegatedAdminGateDeps type from that module, FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS and assertFlowSystemIdentityWriteAllowed are not re-exported, and package.json maps a single . entry to dist/index. No published accept set or public surface widens, so Clause-②: no is RIGHT and the changeset's "the package's exports are unchanged" is true.
  6. Unchanged paths. Every platform writer's { isSystem: true } carries no flowRunId (service-automation's own stores and credential channels included: SYSTEM_CTX literals without a run id); plugin-approvals reads provenance.flowRunId and constructs no isSystem plus flowRunId context; a runAs: 'user' run carries flowRunId without isSystem and reaches the D12 gate as the user, with granted_by stamped by stampWriter. The seeder, read and other-object controls and the two runAs: 'user' cases pin each row of the PR's table. RIGHT.
  7. Prescription. The four organization endpoints named (invite-member, cancel-invitation, update-member-role, remove-member) resolve in auth-route-ledger.ts; runAs: 'user' for the two grant tables is the ruling's own remedy. RIGHT.
  8. Prose faces. The changeset's FROM, TO, Unchanged and Fix sentences each match a pinned behaviour. system-context.mdx row 1b: anchor flow-system-identity-write-refusal.ts#assertFlowSystemIdentityWriteAllowed resolves at the head; "the one check ahead of the middleware's short-circuit" is true (the epoch bump ahead of it is a memo retirement, not a check); "Lose: nothing a platform writer held" is true; no row-number reference. The delegated-admin-gate.ts header and GRANTED_BY_OBJECTS docblock and the resolveRunDataContext docblock say what the code now does. RIGHT.
  9. Tests assert code, status 403 and details on the refusal and read the store back; the integration pin asserts innerRefusal.code through the real engine. The ablation (8 and 5 reddened, controls green, restore clean) is reported pinned to a6bc84f36e and the final battery to 648d903049. The check-runs on the head are the gate verdicts: all success or skipped.
  10. Interaction with refactor(plugin-security): move the read filters, field readers and object admissions out of security-plugin.ts (#25223 cut 9) #25456 (landed on main after the PR's CI): it moved the read-filter, field-reader and object-admission bodies out of security-plugin.ts and re-pointed rows 3 and 4 of system-context.mdx. On current main the epoch bump and the isSystem bypass still sit together in the data middleware (main lines 2601 to 2606), the import block still holds the DelegatedAdminGate line the PR's import follows, and row 1b's anchor lives in the PR's new module. The merge is textually clean and no semantic interaction is plausible: the refusal's seam is the middleware prologue, which refactor(plugin-security): move the read filters, field readers and object admissions out of security-plugin.ts (#25223 cut 9) #25456 did not touch. Not run.

② Semver level

@objectstack/plugin-security: minor, summary with !, **BREAKING** banner, Clause-②: no (narrowing), Metadata-protocol: semantic, on the PR body and the changeset alike. RIGHT and mutually consistent: (narrowing) is BREAKING (AGENTS.md step 3); the kind is semantic because runAs keeps both values and every flow still saves while what runAs: 'system' lets a data node write changes; a kind takes at least minor on the package the diff moves; and the pre-GA rule of ADR-0087's ratified launch-window section is that a break ships minor carrying the banner and its ADR-0087 disposition, with check-changeset-no-major refusing a major. The launch-window convention the changeset cites is that section.

ADR-0087 marker: not-required (no-migration-prescription). Admitted by the gate (pr-automation ran it at the merge base, success), but only through the detector's documented blind spot: the body's FROM / TO / Fix bullets carry no arrow rewrite and no framed table. In substance the body does prescribe an author rewrite (set runAs to 'user'), and ADR-0087's ratified section wants one D3 semantic entry per family "in every case" and "in the same release". The ruling assigned that entry to PR2 (packages/spec, spec lane), so the closed vocabulary has no exact category for PR1 and the marker's why says in writing where the entry is, which is the gate's stated purpose. Judged RIGHT for PR1 on that basis, with one condition for the seat in ③ (PR2 must ship in the same release as this minor). The precedent #21649 avoided the question by shipping patch with no narrowing arm; this PR's stricter declaration is the better one and is what the claim stated.

③ Boundary flags

  • Deviation 1 (GOVERNED_OPERATIONS export and ReadonlySet type, claimed comment-only): accepted; see ① 5, no public surface moves, and one verb list for two checks is the right shape.
  • Deviation 2 (system-context.mdx row 1b, outside the claimed surface): accepted; check-system-context-census requires a row per isSystem read site, and the row is accurate (① 8).
  • Deviation 3 (commit trailer reworded before push): no contract effect.
  • Open question 1 (who amends ADR-0073's "stays god-mode throughout" and ADR-0096's row for runAs: 'system'): ANSWERED, option A. The sentence is falsified by the ruling, not by this PR alone, and the declaring half of the ruling is PR2's; docs/adr is outside this card's surface, and the precedent fix(service-automation): flow write nodes refuse a stored-metadata family target (#21624) #21649 left its ADR to the spec half too. The seat's hand-off pointer 6368756448 should name both ADR lines (ADR-0073 M2 and the ADR-0096 D5 table row) so PR2's reviewer reads them as part of its scope. Until PR2 lands both sentences read stale against main; that is the ruling's accepted order.
  • Open question 2 (extend the refusal to a runAs: 'system' hook fired by a human request, and to an action body's caller-plus-isSystem envelope, neither of which carries a flowRunId): ESCALATED to the maintainer through the seat, option A (file a finding, no code in this PR). The ruling's predicate is provenance-keyed, so the letter already produces an asymmetry worth naming in that finding: a runAs: 'system' or inherit hook that writes one of the four objects is refused when a flow's write (a runAs: 'user' flow's included, since sudo() spreads the run's context) fires it, and admitted when a human request fires the same hook. Extending or narrowing that is beyond the letter and beyond this PR; the provenance marker each path would need is HookContext.provenance for hooks and the caller envelope for action bodies.
  • Out-of-scope finding (cascade and set_null legs): within the letter, census 0, consequence stated on the PR and in the changeset; a system flow's delete of a parent row with holdings now fails whole, and the prescription (runAs: 'user', D12 judging each leg) applies.
  • Out-of-scope finding (flows.mdx and access-recipe pages still describe runAs: 'system' as elevated with no mention of the refusal): prose face, true for ordinary records; carrier is PR2 or a docs pass. Noted, not a condition.
  • Condition for the seat, from ②: this minor ships a declared break whose D3 entry is PR2's. Per ADR-0087's ratified section the entry lands in the same release; the card parks pm:blocked on PR2's card after this lands, and the release cut must not precede PR2.

Implemented-by: claude/project-thread-t123rb
Reviewed-by: session_016g7nYP9wdNx1GDa5XDr7Uy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · 2026-10-23T16:26Z

domain:services seat 1 (seat post #6021) · os-sales · session_016g7nYP9wdNx1GDa5XDr7Uy · card #25251 (p1 security, card thread; ruling 6354966423 letter B, PR1; claim 6368019403, os-dev-report 6369397150) · reviewed head 648d903049

  • Review of record: the CONTRACT_REVIEW_TIER record above, on this head (narrowing face, a separate read-only reviewer), PASS.
  • Diff: 8 files, +742 / −4.
    • The new flow-system-identity-write-refusal.ts is called once in the data middleware, immediately before the isSystem bypass.
    • GOVERNED_OPERATIONS is exported at module level only. plugin-security's index.ts and its single . package export are unchanged.
    • Comment corrections in delegated-admin-gate.ts and runtime-identity.ts.
    • Row 1b in system-context.mdx.
    • The changeset is minor with the BREAKING banner.
  • Pins (report):
    • 44 + 9 new cases.
    • Ablation turns 8/44 and 5/9 red, and the restore is clean (source and dist/).
    • plugin-security 4509 and service-automation 2628 pass.
    • dispatch-gates 100/100 on the final head.
    • Dogfood battery: 290 files, 2494 tests, 0 failures (owed for ! and (narrowing)).
  • CI on this head: 36 success and 6 expected skips, none failed or pending (42 runs).
  • Merged tree, run by the seat:
  • Declarations:
    • feat(plugin-security)!, Clause-②: no (narrowing), Metadata-protocol: semantic.
    • ADR-0087 not-required (no-migration-prescription): the D3 semantic entry is PR2's by the ruling.
  • Conditions carried forward:
    • PR2 (the spec lane's runAs describe sentence and the ADR-0087 D3 entry) must ship in the same release as this minor. No release cut should precede it.
    • The reviewer's answer to the dev's first open question is that PR2 also carries the ADR-0073 "god-mode throughout" amendment.
    • The second question (other app-authored isSystem elevations without a flowRunId) has gone to the maintainer for ruling. It does not block this PR.
  • Body: the attribution header, both footers and the os-sales reviewer are present.
  • Landing: not governed (check-governed-merges --pr 25483: 746 lines). Ready + auto-merge through the relay. Part of #25251: after merge, the card parks as pm:blocked with Blocked-by: the spec card that spec seat 1 names for PR2.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 23, 2026 16:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 23, 2026 16:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit a285ff2 Oct 23, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-t123rb branch October 23, 2026 17:05
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