Skip to content

feat(security)!: an AI service account is the agent principal of its own requests, under the agent ceiling - #31867

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/project-thread-ffxuzs
Nov 9, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/project-thread-ffxuzs

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Part of #31739

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: follow-up owed — content/docs/ai/agents.mdx, content/docs/permissions/index.mdx (the AI service account and the agent ceiling its sets are read under; an author first meets an account at PR-3's registration door, so the teaching lands with it)

Clause-② measured: no published key, export or shape is removed or retyped; one package-root export is added to core (isAgentAccountRow) and one module export to plugin-security (DESTRUCTIVE_CLASS_KEYS, not re-exported from its root), and the connector-connection door's delegated-caller union gains agent_account. The narrowing: every agent principal's permission sets are read under the agent ceiling, and a non-system write giving an AI service account a set above it is refused. Measured first-party: 0 of the 3 OAuth agent ceiling sets carries a clamped bit, and no account row can exist until PR-3. Changeset level patch, ! in its headline, ADR-0087 not-required (no-migration-prescription).

维护者速读

  • 改了什么:AI 服务账号(principal_kind = agent 的 sys_user)用自己的 API 密钥访问时,就是它自己这个 agent 主体:principalKind: 'agent'、没有 onBehalfOf、拿不到 everyone 锚点权限集、没有系统权限、也永远不是平台或组织管理员。所有 agent 主体的权限集在求值时按「agent 上限」裁掉 View All、Modify All、转移/恢复/清除、系统权限和管理范围;给账号分配超上限的权限集(或绑定它的岗位)会被按名字拒绝。
  • 为什么改:ADR-0151 D3,三步拆分的第二步(PR-1 feat(auth): an AI service account is a sys_user row of kind agent that never signs in #31779 已合并),PR-3 的注册与密钥入口建在它上面。
  • 风险与回滚:解析权限时对 sys_user 的那一次读取改为无条件(原来只在配置了平台管理员时才读),每次解析多一次按主键读取;OAuth 连接的助手所用的 3 个第一方权限集不含任何被裁的位,行为不变(测试实测)。回滚即还原本 PR。
  • 先看:packages/core/src/security/resolve-authz-context.ts(账号分支)、packages/plugins/plugin-security/src/agent-ceiling-clamp.ts 与 security-plugin-permission-sets.ts(唯一求值入口的裁剪)、agent-account-grant-refusal.ts(分配拒绝,留意「调用者读不到持有人就不判」的分支)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-security exec vitest run src/agent-ceiling-clamp.test.ts src/agent-account-grant-refusal.test.ts,应见 20 个用例全绿。
  • 需要你做的:无,席位落地(席位按约定做 CONTRACT_REVIEW_TIER 评审)。

What changes

  • D3, the account is its own subject. resolveUserAuthzGrants reads the user's sys_user row on every resolution (one read, by id, as the system; it used to be read only when a platform-admin config was set). A row whose principal_kind is agent resolves as principalKind: 'agent', without the everyone anchor's sets, with no system permission, and with no platform or organization administrator standing, whatever its grants say. It never carries admin_full_access or an organization administrator set by name either: standing is also read by name (the approvals override arms and admin_rescue slate, the explainer's tenant-admin read), and the ceiling clears bits, not names. assembleExecutionContext carries the kind onto the context, with no onBehalfOf. REST, /mcp and stdio keys all go through this one resolver, so the key admission needed no change of its own.
  • The ceiling, at evaluation. resolvePermissionSetsForContextUnmemoized, the one choke point every evaluator reads, clamps every agent principal's sets (the account and the OAuth-delegated agent alike): viewAllRecords, modifyAllRecords and the destructive class's keys (DESTRUCTIVE_CLASS_KEYS, derived from DESTRUCTIVE_OPERATIONS) are cleared on every object entry, '*' included; systemPermissions is emptied and adminScope removed. A clean set is returned as the same object; a breaching one as a fresh copy, so the catalogs are never mutated. An account's floor is the restricted agent set, so a new account can do nothing until it is granted something.
  • The ceiling, at assignment. A non-system insert or update on sys_user_permission_set or sys_user_position that would leave an AI service account holding a set above the ceiling (directly, or through a position that binds one) is refused 400 VALIDATION_FAILED, invalid_value at permission_set or position, naming the set and what in it breaches. Priority 45, after the ceiling-set (35) and membership (40) refusals. A holder the caller's own door does not read is not judged here (the engine's reference check answers it, and the membership refusal's per-write read is reused), so the refusal never names the kind of a user the caller cannot read. A person's grants are never judged. Registered on the system-context page as row 23g.
  • Inventory. Flow runs (trigger door, action door, run setup, resume), the connector-connection door (new caller kind agent_account), the action audit line, attachment and comment access, the mention gate and the access explainer read an account as the agent it is.
  • Standing guards. principal_kind is listed as an immutable non-standing column in the admin standing surface and the last-admin guard's exclusions.

Nothing registers an AI service account yet; the registration, key and lifecycle doors follow in PR-3 (Fixes #31739).

Tests

New, 55 cases, all green:

  • core: resolve-authz-context.agent-account.test.ts (8), assemble-execution-context.test.ts (+3).
  • plugin-security: agent-ceiling-clamp.test.ts (10, including the measured 0 of 3 first-party OAuth ceiling sets clamped), agent-account-grant-refusal.test.ts (10, real ObjectQL + SQLite with the real SecurityPlugin; two pin the unreadable-holder and no-sys_user stand-downs), effective-access.test.ts (+2).
  • runtime: security/resolve-execution-context.agent-account.test.ts (real resolver + real SecurityPlugin over /mcp and the dispatcher; clamp; everyone and wildcard refused; human and OAuth CONTROLs), flow-run-agent-account-kind.test.ts, automation-connector-connection-door.test.ts (+1).
  • service-automation: run-agent-account-kind.test.ts (5).
  • rest: rest-api-key-agent-account.test.ts (2). mcp: __tests__/stdio-agent-account.test.ts (2).
  • plugin-approvals: agent-account-not-an-administrator.integration.test.ts (6, real ApprovalService on ObjectQL + SQLite, envelopes from the real resolver): an account holding organization_admin is not on the admin_rescue slate and an account holding admin_full_access or organization_admin cannot decide a staffed request (answered as for a missing id, left pending); CONTROLs: a person holding the same grant is on the slate and overrides.

Forced test edits (the resolver's sys_user read is now unconditional, as approved):

  • resolve-authz-context.batch-equivalence.golden.json: the seeded-permissions-and-email case records one more query, {"object":"sys_user","where":{"id":"u_seed"},"limit":1,"isSystem":true}; the header notes it as the sixth recorded move.
  • resolve-authz-context.platform-admin-config.test.ts: the describe is renamed to say the read is one read, config or not; "fully-seeded" now expects one read and no PLATFORM_ADMIN; "…and costs no sys_user read of its own" now expects one read on both postures.
  • resolve-user-grants-cache.test.ts pin 5: sys_user calls are two, one per real resolution.

Forced edits to this PR's own new fixtures after the contract review (F1, the account no longer carries administrative set names): resolve-authz-context.agent-account.test.ts ("is the agent kind…" now expects admin_full_access absent; "an organization administrator set confers no organization standing…" now expects organization_admin absent on the account and present on the person; plus a new pin over every ORGANIZATION_ADMIN_GRANTS name), and resolve-execution-context.agent-account.test.ts (the held sets contain crm_lead_super and not admin_full_access).

Acceptance notes

  • PR-2 of the approved three-PR serial split. The card stays open; PR-3 carries the D4/D6 doors and the ADR-0151 status-line fix.
  • The account's kind is read from the resolver's row read, not from the key admission projection: adding the column there could refuse keys on a store without it, and the resolver serves every caller.
  • scripts/engine-double-contract.pinned.json gains one row for the new runtime test's findOne double, written by the gate's own --write, as its message asks.
  • Seat: CONTRACT_REVIEW_TIER review owed on this PR.

Verification

Tests and typecheck

All under scripts/pm/os-verify-lock.sh, npx vitest run per package, after merging main at 015ae70:

  • core 3301 passed; plugin-auth 4443 passed / 10 skipped; plugin-audit 1225; service-automation 3498; service-storage 1428; objectql 11821; rest 7940 passed / 355 skipped; mcp 981.
  • plugin-security: the first run had 8 reds in grant-holder-membership-refusal.test.ts and tenant-wall-organization-removal.test.ts (the account refusal read an unreadable holder as the system, and threw where no sys_user object is composed). Fixed in 0c2ef42; the re-run at the final head: 6056 passed / 45 skipped, 0 failed.
  • runtime: 10513 passed / 19 skipped, 1 failed: sandbox-relay-unique-violation-wire-code.test.ts "a script action body that lets the refusal of its write leave" timed out at 5000 ms while the gate loop shared the box; re-run alone, the file passes (13 of 13 with the agent-account file).
  • tsc --noEmit: core, plugin-security, plugin-audit, service-automation, service-storage, objectql, rest, mcp, runtime, exit 0.
  • eslint --no-inline-config on every touched .ts file: exit 0.
  • Full build (turbo run build --filter='./packages/**', lock): 81 of 81 tasks.
Full dogfood battery (the `(narrowing)` arm)

pnpm --filter @objectstack/dogfood test at the final head 0c2ef42, under the lock, after the full build: 441 files passed / 3 skipped, 3662 tests passed / 42 skipped, 0 failed (VERDICT command-exit 0, 32m40s).

Ablations (each red, then restored: `git diff HEAD` empty)

Each through scripts/ablation-replace.mjs, committed first; the named pins went red and the file came back with blob == HEAD.

  • core A1–A11: the everyone skip, the system-permission zeroing, the anchor and config standing checks, the kind stamp, the row predicate, the context copy, the organization-admin standing, and the assembler's account arm, system permissions and precedence.
  • plugin-security B1–B10: the clamp wiring, the cleared bits, system permissions, admin scope, the destructive keys, the refusal verdict, its holder read, its registration, the update image, and the effective-access baseline.
  • runtime C1–C4: trigger and action forwarding, the trigger CONTROL, the connector caller kind. service-automation D1–D3: run seed, run data context, unseed. E1: the runtime end-to-end clamp.
  • F1: the kind on the runtime face. F3: REST and stdio read the core dist, so that leg was held, core rebuilt, both red, then restored and rebuilt green. (F2, the same leg without the rebuild, stayed green and measured nothing.)
  • G1: the unreadable-holder stand-down removed: the new pin and the membership refusal's "a holder the caller cannot read is read only through the caller's own door" both red. G2: the no-sys_user guard removed: the composition pin red.
  • Several first attempts used a replacement that the tool's own count refused (a substring of the anchor, or a common string); they measured nothing and were re-run with distinct replacements.
Delta after the contract review (head 1166ca5)
  • CI: Type Check · workspace was red at 0c2ef42 on @objectstack/runtime#typecheck: a new test read executionContext.onBehalfOf where the test tsconfig types it possibly undefined. Fixed (a415adc); the new approvals file had the same class of error, fixed (1166ca5). check:test-typecheck OK on all eleven touched packages.
  • F1 (e26e44a): the resolver withholds admin_full_access and every ORGANIZATION_ADMIN_GRANTS name from an account's permissions. Ablation H1 (filter removed, held, core rebuilt for the dist-reading approvals suite): core 3 pins, approvals 4 pins and the runtime rig red; restored, rebuilt, green, git diff HEAD empty.
  • Suites under the lock at e26e44a: core 3302 passed; plugin-approvals 1508 passed; runtime 10514 passed / 19 skipped. tsc --noEmit exit 0 for all three. eslint exit 0.
  • Dispatch gates at e26e44a: 120 derived, 120 run, all exit 0 (1166ca5 changes one test line only).
  • Readers of those names, grep over packages/ (non-test): on an envelope's permissions, plugin-approvals isOverrideActor and holdsTenantAdminRung (closed here) and plugin-security explain-engine.ts#derivePosture on a posture-less context (closed here). On stored grant ROWS, which the resolver change does not reach: plugin-auth last-admin-guard.ts (counts a Default Organization admin_full_access row as an administrator) and plugin-security upgrade-report-delivery.ts (ADMINISTRATOR_SETS, report recipients). An account holds such a row only through a system or seed writer; both are named for PR-3, where an account's rows are born.
Dispatch gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran: 120 derived, 120 run, 0 NOT-MEASURED, every exit 0 at 0c2ef42.
First round (82 families, before the docs row widened the derivation to 120) had six reds, fixed in 211f6ce (the changeset) and 0c2ef42 (the rest):

  • check-adr-0087-registration: the narrowing had no disposition marker; the changeset now carries not-required (no-migration-prescription) and the BREAKING banner.
  • check:engine-double-contract: the clamp test's undriven findOne double dropped; the runtime test's pinned findOne recorded by the gate's own --write.
  • check:objectql-double-limit: the REST double applies limit after the filter.
  • check:where-matcher: the stdio double refuses a combinator or operator.
  • check:test-source-alias: module-top import '@objectstack/objectql' (REST) and import '@objectstack/metadata' (connector door test).
  • check:system-context-census: row 23g added and the register regenerated.
  • check:type-check-debt hit the 600 s loop timeout on the first round; it ran to exit 0 in the second.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58

…wn requests, under the agent ceiling

The grants resolver reads the caller's own sys_user row on every resolution
and reports an account (principal_kind = agent) as principalKind 'agent':
no everyone anchor, no system permission, no platform or organization
administrator standing. The shared assembler stamps it as the agent
principal with no onBehalfOf, so its own grants are its whole authority.

The security plugin clamps every agent principal's sets at evaluation: no
View All, Modify All, transfer, restore or purge, system permission or admin
scope. A set assigned to an account that breaches the ceiling is refused by
name at sys_user_permission_set and sys_user_position.

Run, storage, mention, explain and scoped-engine envelopes carry the kind;
the flow trigger and action doors forward it with the account's grants.

Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58
Co-authored-by: Claude <noreply@anthropic.com>
…ount no organization standing

Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58
Co-authored-by: Claude <noreply@anthropic.com>
… the agent floor as its default

Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58
Co-authored-by: Claude <noreply@anthropic.com>
… a holder the caller can read

A grant naming a holder the caller's own door does not return is left to the
engine's reference check, as the membership refusal leaves it; the account
refusal no longer reads that holder as the system, so its answer cannot name
the kind of a user the caller cannot read. A composition with no sys_user
object has no account to judge. The caller's read is shared with the
membership refusal through one per-write memo.

Registers the bypass on the system-context page, and pins the new test
doubles the way the double gates require.

Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 10 package(s): @objectstack/core, @objectstack/mcp, @objectstack/objectql, @objectstack/plugin-audit, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/rest, @objectstack/runtime, @objectstack/service-automation, @objectstack/service-storage, touching 88 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/core/src/security/index.ts, packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json, packages/plugins/plugin-audit/src/comment-access-hooks.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

64 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 ac8fede8f938439d2228f5b1404042d113a3812e.

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

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/core/src/security/index.ts, packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json, packages/plugins/plugin-audit/src/comment-access-hooks.ts, …) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 50 pages), sys_user (literal, 50 pages)
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 231 client-bound route-ledger rows — the other 166 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 166: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 107 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 — 95 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 ac8fede8f938439d2228f5b1404042d113a3812e → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 ac8fede8f938439d2228f5b1404042d113a3812e → 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:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi

Contract-review-claim: session_01ADHkmPF1skfu2kx89BEXZi · head 0c2ef42 · 2026-11-09T08:20Z

Narrowing face (Clause-②: no (narrowing)). An isolated reviewer is reviewing this head at CONTRACT_REVIEW_TIER.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0c2ef4252b3ed241d7091c490127d9ecae2f0d3f
Local-runs: none
Scope: packages/core/src/security/admin-standing-surface.ts, packages/core/src/security/assemble-execution-context.ts, packages/core/src/security/index.ts, packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/resolve-authz-context.agent-account.test.ts, packages/core/src/security/assemble-execution-context.test.ts, packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json, packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts, packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts, packages/core/src/security/resolve-user-grants-cache.test.ts, packages/mcp/src/plugin.ts, packages/mcp/src/tests/stdio-agent-account.test.ts, packages/objectql/src/engine-scoped-context.ts, packages/plugins/plugin-audit/src/comment-access-hooks.ts, packages/plugins/plugin-audit/src/mention-recipient-gate.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-security/src/agent-account-grant-refusal.ts, packages/plugins/plugin-security/src/agent-account-grant-refusal.test.ts, packages/plugins/plugin-security/src/agent-ceiling-clamp.ts, packages/plugins/plugin-security/src/agent-ceiling-clamp.test.ts, packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts, packages/plugins/plugin-security/src/effective-access.test.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts, packages/plugins/plugin-security/src/permission-evaluator.ts, packages/plugins/plugin-security/src/security-plugin-effective-access.ts, packages/plugins/plugin-security/src/security-plugin-permission-sets.ts, packages/plugins/plugin-security/src/security-plugin-start-hooks.ts, packages/rest/src/rest-server.ts, packages/rest/src/rest-api-key-agent-account.test.ts, packages/runtime/src/action-execution.ts, packages/runtime/src/domains/automation.ts, packages/runtime/src/flow-run-agent-account-kind.test.ts, packages/runtime/src/automation-connector-connection-door.test.ts, packages/runtime/src/security/resolve-execution-context.agent-account.test.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/plugin.ts, packages/services/service-automation/src/run-resume.ts, packages/services/service-automation/src/runtime-identity.ts, packages/services/service-automation/src/run-agent-account-kind.test.ts, packages/services/service-storage/src/attachment-access-hooks.ts, packages/services/service-storage/src/storage-service-plugin.ts, scripts/engine-double-contract.pinned.json (generated by the gate's own --write; read for provenance, not reviewed) + cited code read on the head and never reviewed: packages/core/src/security/resolve-authz-context.ts (tryFind, getUserRow, hasPlatformAdminStanding, AuthzStoreUnavailableError), packages/core/src/security/resolve-user-grants-cache.ts (the cache key), packages/core/src/security/api-key.ts and user-ban.ts (USER_BAN_FIELDS), packages/plugins/plugin-security/src/security-plugin-permission-sets.ts (permissionSetMemoKey), packages/plugins/plugin-security/src/security-plugin.ts (describePositionGrants, resolveFallbackPermissionSets, every resolvePermissionSetsForContext host wiring), the fourteen host modules that call resolvePermissionSetsForContext (authz middleware, read filter, field readers, object admission, operation principals, sharing verdicts, authored-row write, explain access, security service, effective access), packages/plugins/plugin-security/src/objects/default-permission-sets.ts (mcp_agent_data_read, mcp_agent_data_write, mcp_agent_restricted, organization_admin, deriveWallLessOrgAdmin), packages/spec/src/ai/mcp.zod.ts (MCP_AGENT_PERMISSION_SETS), packages/spec/src/identity/eval-user.zod.ts (ORGANIZATION_ADMIN_GRANTS, ADMIN_FULL_ACCESS_CAPABILITIES), packages/plugins/plugin-approvals/src/approval-service.ts (isOverrideActor, holdsTenantAdminRung, the admin_rescue slate), packages/plugins/plugin-security/src/explain-engine.ts (derivePosture), packages/runtime/src/domains/mcp.ts (the oauthScopes tool-family gate), packages/runtime/src/action-execution.ts (apiForwardAgentOf), packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts (referenceCheckAsksCaller, callerReadsHolder), docs/adr/0151-ai-service-account-a-headless-agent-is-a-registered-principal-of-its-own.md on main. Prose faces read for ② and ③, not reviewed: .changeset/31739-ai-service-account-ceiling.md, content/docs/permissions/system-context.mdx.

Inputs: card #31739 (body and all five comments, the PR-1 and PR-2 os-dev-reports with their open questions), PR #31867 (body, file list, net diff against main at the head; merge-base ad622d30), the 64 check-runs on the head as of 08:28Z. Check-runs: every completed run is success (Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Spec property liveness, Lint & Repo Gates, Lint · ESLint / docs & code guards / repo & tooling / CI, release & engine / PM dispatch-gates / three scoped lints, Type Check · source gates, Build UI Deps, Build Docs, Build & E2E UI, Live E2E UI, Dogfood Regression Gate, Flag docs, Check Documentation Links, Auto Label); still pending at the time of this record: Build Core, Test Core (1–8), Type Check · workspace / consumer gates / debt ledger, Type Check UI, Test UI (1–16). No run has failed. The derived gate families those pending runs answer (the admin-standing-surface exactness pin, the batch-equivalence golden, the plugin-security and runtime suites) are read here from the tree, not from a local run; the queue leg still needs them green.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong against ADR-0151 D3 on main:

  1. The resolver's sys_user read is unconditional (resolve-authz-context.ts, getUserRow() joins wave 1 for every caller). Right. The only path that skipped it was a caller seeding both an email and ai_seat; key scopes are refused in keys.ts, so no live caller seeds ai_seat and no live path gains a read. The read is memoised (userRowLoaded), so the config-anchor and ai_access reads below reuse it: one read by primary key per resolution, as the forced pins now assert (platform-admin-config: 1 on both postures; cache pin 5: 2 for two real resolutions, 0 on a hit). Fault handling: tryFind throws AuthzStoreUnavailableError for any failed read that is not a missing table (commit 6a180e4), so a store fault on the kind read fails LOUD and closed, never as a person; a missing sys_user table ([]) reads as a person, which is the pre-column store and correct. The ADR's O3 paragraph had the kind carried on key admission's USER_BAN_FIELDS read instead; the tree reads it in the resolver, which every key face (REST computeExecCtx, /mcp, stdio) passes through, so the admitted set is identical and the dev's reason (a projected read naming a column an unmigrated store lacks could refuse every key) holds — nit F3, no contract effect.
  2. An agent row resolves as principalKind: 'agent', is withheld the everyone anchor (!agentAccount && !holdsExternalIdentity(...)), carries systemPermissions: [], gains no platform standing from an anchor grant (anchorsPlatformStanding(grant) && active && !agentAccount) nor from the configured-administrator address (!agentAccount && ...), and isTenantAdmin is false whatever ORGANIZATION_ADMIN_GRANTS it holds, so posture is MEMBER. Right against D3 (explicit grants only; never an administrator). hasPlatformAdminStanding is posture === 'PLATFORM_ADMIN' over the same resolution, so the id-shaped predicate answers false for an account too (pinned). isAgentAccountRow reads principal_kind then principalKind, the exact literal 'agent' only; every other value and an absent row is a person — right, and matches plugin-auth's sign-in refusals.
  3. The shared assembler: account = !anonymous && authz.principalKind === 'agent' takes precedence over the OAuth arm; the account gets principalKind: 'agent', systemPermissions: [], its own permissions, no onBehalfOf, no performedBy, no oauthScopes. Right. The precedence is safe because D2 (PR-1, merged) refuses every session for an agent row, so no OAuth consent and no token can name an account; the OAuth-delegated CONTROL keeps today's shape byte for byte (pinned in assemble-execution-context.test.ts and the runtime rig).
  4. The clamp sits at the one choke point. resolvePermissionSetsForContextUnmemoized applies clampPermissionSetsToAgentCeiling after both resolutions (the named sets and the restricted fallback) for every principalKind === 'agent' context. Read on the head, every evaluator reaches sets through the memoised resolvePermissionSetsForContext: the authz middleware (object CRUD, VAMA, write-check, pre-image floor), the read filter and RLS, field readers (FLS masks and forbidden writes), object admission, operation principals (the batch path), all four sharing-verdict hosts, authored-row write, explain access, the security service and effective access. The two direct permissionEvaluator.resolvePermissionSets calls outside it are describePositionGrants (a description of a position's declared sets, no evaluation) and resolveFallbackPermissionSets (the HUMAN baseline, never reached on the agent branch, which floors at the restricted set). permissionSetMemoKey already keys on principalKind, so a person and an agent naming the same sets never share a memo entry; the grants cache keys on (userId, tenantId, seeds) and caches the envelope with its principalKind. Right: no cache, batch, FLS, sharing or explain path bypasses the clamp.
  5. What the clamp clears: viewAllRecords, modifyAllRecords and DESTRUCTIVE_CLASS_KEYS on every objects entry including '*'; systemPermissions / system_permissions emptied; adminScope / admin_scope removed; JSON-string columns parsed as the delegated-administration gate parses them; a clean set returned as the same object, a breaching one as a fresh copy (catalogs never mutated). DESTRUCTIVE_CLASS_KEYS is derived from DESTRUCTIVE_OPERATIONS through OPERATION_TO_PERMISSION, so it reads allowTransfer, allowRestoreOwnDeleted, manageRecycleBin and follows the map. With both bypass bits cleared a '*' entry stops being the superuser wildcard (ADR-0066 D2 reads the two bits; the field merge's superuser vote reads the same two). Right against D3's list: View/Modify All, the destructive class, a system permission, a superuser wildcard, an admin scope.
  6. The refusal at assignment (agent-account-grant-refusal.ts): beforeInsert/beforeUpdate at priority 45 on sys_user_permission_set and sys_user_position, after the ceiling-set (35) and membership (40) refusals; judges the row image AFTER the write (payload else previous); stands down on isSystem, on no sys_user object in the composition, on a holder the caller's own door does not read (callerReadsGrantHolder === false), and on a catalog read fault (warn, proceed); refuses 400 VALIDATION_FAILED / invalid_value at permission_set or position naming the set and each breaching bit. Right: the loud early answer D3 asks for, the clamp the guarantee. A person's grant is never judged (the isAgentAccount read gates it), so no existing write narrows. Pinned on a real ObjectQL + SQLite engine with the real SecurityPlugin, including the update that moves a person's breaching grant onto the account. One nit (F2 below).
  7. callerReadsGrantHolder (new export of grant-holder-membership-refusal.ts): the membership refusal's per-write memoised caller read, now returning the tri-state (undefined when the dispatch carries no caller api) and holderLeftToReferenceCheck re-derived as reads === false. Right; the membership refusal's own behaviour is unchanged (its pins run in the plugin-security suite).
  8. Inventory: dispatchFlowAction and buildAutomationContext forward principalKind: 'agent' only for an agent WITHOUT onBehalfOf; seedRunGrants seeds it from the resolver beside the grants and deletes it with them (needsGrants), and the decider switch drops it with the trigger user's other facts; resolveRunDataContext carries it onto every CRUD node; the action audit line names the account as acting for itself; connectorConnectionDelegatedCaller returns the new agent_account member before oauth/api_key (still refused by that door, as every non-person caller is); the comment, attachment and mention gates and buildContextForUser stamp the kind; describeEffectiveAccess lists the restricted set as the account's default. Right. Read beyond the inventory: apiForwardAgentOf returns undefined for an account (no oauthScopes), so an api action forward carries the account's own key — D3 rule 5 as written; the /mcp door treats an undefined oauthScopes as "not scope-limited", so the account's tools are bounded by its grants alone — D3's MCP paragraph as written. No remaining reader of principalKind === 'agent' in the tree assumes onBehalfOf or oauthScopes (grep over packages/, non-test).
  9. Standing guards: ADMIN_STANDING_SURFACE.sys_user.columns gains principal_kind and principalKind with a reason, and STANDING_KEY_EXCLUSIONS.sys_user carries both with the immutability reason. Right; the exactness pin (admin-standing-surface.test.ts, "declares exactly the columns read") is in Test Core, pending.
  10. Public surface: core root gains isAgentAccountRow (widening). ResolvedAuthzContext.principalKind, UserAuthzGrants.principalKind, FlowUserGrants.principalKind, RunIdentityContext.principalKind, SeededRunContext.principalKind are new optional fields (widening). DESTRUCTIVE_CLASS_KEYS, the clamp functions, registerAgentAccountGrantRefusal, callerReadsGrantHolder and runPrincipalKind are module exports, not package-root exports (plugin-security's root exports only PermissionEvaluator from that module; service-automation's root export list from runtime-identity.js does not name runPrincipalKind); ConnectorConnectionDelegatedCaller is not re-exported from the runtime root. Nothing is removed or retyped. The PR body's "one export is added to plugin-security" overstates a module export as a package export — nit F4.
  11. WRONG at one consumer (should-fix F1): the account's envelope keeps the NAMES of the sets it holds on permissions (the test fixtures assert admin_full_access stays named; the clamp acts on bodies), and packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor derives standing from those names — perms.includes(ADMIN_FULL_ACCESS) is the platform arm (it crosses the tenant wall) and holdsTenantAdminRung(perms) (ORGANIZATION_ADMIN_GRANTS.some(...)) the tenant arm, each deliberately kept beside the posture read "so a transport that never resolved posture still reads the held capability"; holdsTenantAdminRung also builds the admin_rescue administrators' slate over each candidate's resolveUserAuthzGrants. So an account whose grant row names admin_full_access or organization_admin* is an approvals override actor (approve, reject, reassign, recall any PENDING request; across every organization for admin_full_access) and a rescue-slate administrator — standing the changeset says it never has, "whatever it holds". Every one of those three sets breaches by body (ADMIN_FULL_ACCESS_CAPABILITIES carries the '*' bypass bits and system permissions; organization_admin carries them; organization_admin_no_bypass carries organization_admin's system permissions verbatim), so a NON-system assignment is refused by name; the row is reachable through a system or seed writer (row 23g admits it), through the refusal's own stand-downs (no hook registry, catalog read fault, unreadable holder), and no account row can exist before PR-3. The clamp cannot close it, because a name is not a bit it clears. Grade: should-fix. Fix: withhold the admin-standing names (ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS) from an account's permissions in resolveUserAuthzGrants — the changeset's own remedy says an administrative grant "is a person's to hold" — or make the two approvals arms read posture alone when context.principalKind === 'agent'; either way pin it (an account holding admin_full_access is not an override actor and not on the rescue slate), and correct the changeset sentence if the name-withholding is not chosen.

② Semver level

Changeset .changeset/31739-ai-service-account-ceiling.md: patch on all ten packages the diff moves (core, mcp, objectql, plugin-audit, plugin-auth, plugin-security, rest, runtime, service-automation, service-storage; platform-objects is not touched, correctly absent). Both declarations line-initial in the PR body and the changeset body: Clause-②: no (narrowing) and Metadata-protocol: none. The level follows the kind alone: none takes patch; the (narrowing) arm is BREAKING, so the ! headline, the BREAKING banner and the ADR-0087 disposition not-required (no-migration-prescription) are present and correct (no key, export or shape removed or retyped; nothing stored to migrate; no tombstone). The banner carries the before/now/remedy and the measured first-party count (0 of 3). Check Changeset is green on the head. Right. Nit: the changeset's "is never a platform or organization administrator, whatever it holds" is the sentence F1 makes untrue at one consumer until the fix lands; the fix either makes it true or rewords it.

Clause-②: no (narrowing) — confirmed on the tree: no published key, export or shape is removed or retyped; the only published accept-set change is a narrowing of what an agent principal's sets grant at evaluation and one refused class of non-system assignment write.

③ Boundary flags

  • Dev flag (1) — the assignment refusal's unreadable-holder stand-down and callerReadsGrantHolder. Answered. The first cut read an unreadable holder as the system (8 reds in the membership and tenant-wall suites); the head reuses the membership refusal's per-write memoised caller read and stands down when it answers false, so the refusal never names the kind of a user the caller cannot read; the clamp still holds for such a row. Consistent with the membership refusal's discipline and with ADR-0090 D6's indistinguishability. Nit F2: the account refusal does not consult referenceCheckAsksCaller first (the membership refusal does), so it stands down on an unreadable holder even where the engine's reference check reads as the system and will not refuse the row either; the grant is then stored unjudged and clamped at evaluation. Row 23g states the stand-down. No correctness effect.
  • Dev flag (2) — off-surface edits. content/docs/permissions/system-context.mdx row 23g plus the regenerated register are what check:system-context-census demands for a new isSystem stand-down (both hooks named with their rows); the Lint gates on the head are green. scripts/engine-double-contract.pinned.json gains one row written by that gate's own --write for the runtime rig's driven findOne double. Both accepted as the gates' own output, not reviewed.
  • Dev flag (3) — skills/docs follow-up owed (content/docs/ai/agents.mdx, content/docs/permissions/index.mdx). Acceptable to defer: an author first meets an account at PR-3's registration door, and documenting a door that does not exist advertises a capability the runtime does not yet deliver (Prime Directive chore: version packages #10). Condition: PR-3 carries the teaching in the same PR as the door; the card keeps the debt visible. No escalation.
  • Dev flag (4a) — a runAs:'system' flow triggered by an account. Out of the declared narrowing: ADR-0151 Non-goals list "a flow or job runAs naming an account (ADR-0073's automation principal stays the identity of user-less runs)" as not decided, and D3's "never runs isSystem" reads on the principal, not on a flow the author declared system. A runAs:'system' run is the flow author's grant and is the same system run for any member who may trigger it; the account is the trigger, not the acting identity, and the trigger door now carries its kind. Refusing it at the trigger door would narrow every automation an account may trigger, which is a narrowing beyond this PR's declaration and ADR-0151's scope: it goes to the maintainer (an ADR-0151 amendment or an O6 decision), not into PR-3. B is safe: it adds no power the account did not have the moment a flow author chose runAs:'system', and O2/O6 are the ADR's named readers of principalKind. Recommend B with a follow-up card that names the question.
  • Dev flag (4b) — sets bound through identity positions (org_owner, org_admin) clamped but not withheld. Within the declared narrowing's mechanism: the clamp is what the PR declares, and the positions reach positions as a projection the approvals and security plugin deliberately no longer read as authority. Withholding them would be a further narrowing on accounts only; nothing writes one today (PR-1's reconciler binds no identity position to an account; a non-system sys_user_position write naming a position whose sets breach is refused by this PR's position arm), so B is safe for this PR. PR-3 may revisit with its own declaration, since registration is where an account's membership row is born; it is not a maintainer question. Note the same name-based reader as F1 applies to permissions names, not to position names.
  • Open question from the PR-1 report (ADR-0151 status line reads Proposed). Still open; docs/adr/** is Tier H and outside this PR, as it should be. The PR body says PR-3 carries the fix; a Tier H edit needs the maintainer's merge, so PR-3 becomes Tier H if it touches the ADR — flagging so the seat plans it (an amendment card to the spec/docs lane, the PR-1 report's option A, keeps PR-3 Tier S).

④ Design judgment

The narrowing against the published spec. ADR-0151 D3 on main (merged cae8d8fe) already states every refusal this PR makes real: explicit grants only (no everyone anchor, no member baseline, the restricted floor); rule 2's ceiling held at evaluation (View/Modify All, the destructive class, a system permission, a superuser wildcard, an admin scope); refusal by name at assignment; never an administrator. Nothing is taken from a consumer that the spec declared: an OAuth-delegated agent's three first-party sets (mcp_agent_data_read = {'*': {allowRead}}, mcp_agent_data_write = {'*': CRUD} plus the managed-write denies, mcp_agent_restricted = {}) carry none of the cleared bits, read in default-permission-sets.ts on the head, so the measured census (0 of 3 clamped) is credible from the tree and is pinned over the live defaultPermissionSets list with identity (toBe) on each. A deployment that edited its own mcp_agent_* set to carry a bypass bit sees a narrower answer, which is exactly what the changeset's BREAKING banner says.

Human principals and the OAuth CONTROL. Every behavioural branch is gated on agentAccount (resolver) or principalKind === 'agent' (plugin-security, assembler account arm, automation doors). A person holding the same rows as the account resolves with the everyone anchor, the system permission and platform standing (CONTROLs in core, rest, mcp, runtime, plugin-security). The OAuth-delegated agent keeps onBehalfOf, performedBy, scope-derived permissions and the delegator's systemPermissions under delegatesActions; its side of the intersection is clamped by the same line, a no-op on the first-party sets; the delegator's side (principalKind: 'human') is untouched. Correct.

Bypass hunt. Cache: the grants cache (resolve-user-grants-cache.ts) keys on identity and seeds and stores the envelope with principalKind; the set memo keys on principalKind. Batch: resolveOperationPrincipals is a host of the memoised resolver. FLS, sharing (all four verdict hosts), explain, effective access: hosts. Impersonation: a session for an agent row is refused by PR-1's sessionBefore. The OAuth ceiling path: the assembler's account arm precedes it and no token can name an account. Automation: the kind is seeded, forwarded, carried to every CRUD node and dropped at the decider switch; a body-supplied principalKind stays a flow param (pinned). Found one: the name-based standing read in plugin-approvals (F1, should-fix) — a standing consumer outside the ceiling's choke point that the clamp cannot reach because it reads set names, kept on the account's envelope by design. Grade stands at should-fix, not must-fix: the declared narrowing (clamp and refusal) is implemented at the one choke point every evaluation passes; the leak needs a system or seed writer to store an administrative set's name against an account, no account can exist before PR-3, and the fix is one guarded line in the resolver or the approvals gate plus a pin.

The unconditional sys_user read. Fail-closed (AuthzStoreUnavailableError on any read fault but a missing table), one primary-key read already memoised for the config anchor and ai_access, added only on the fully-seeded path no live caller takes, recorded as the sixth golden move with the pin's header saying why. Safe. The ADR's O3 paragraph placed the kind on USER_BAN_FIELDS; the resolver placement is a sound deviation (nit F3) and reaches every face, including a stdio process and an automation run that key admission never sees.

The refusal. Priority ordering, the post-write image on update, the previous binding, the holder-moved case and the system stand-down are right and pinned on a real engine. The catalog-fault branch proceeds with a warn rather than refusing; acceptable because the clamp is the guarantee and the sibling refusals do the same. isAgentAccount compares a placeholder-shaped id by $startsWith on its first character with limit: 2, as the membership refusal does; a placeholder-shaped id is never a stored holder, so this has no effect (nit F4b, folded into F4).

Findings.

  • F1 — should-fix: plugin-approvals#isOverrideActor and the admin_rescue slate read admin_full_access / ORGANIZATION_ADMIN_GRANTS by NAME off permissions; the account's envelope keeps those names; the clamp clears bits, not names. Withhold the names for an account in the resolver (or read posture alone for principalKind === 'agent' in both arms), pin it, and keep or reword the changeset sentence.
  • F2 — nit: the account refusal stands down on an unreadable holder without first asking referenceCheckAsksCaller, so where the reference check reads as the system the row is stored unjudged; clamp holds; row 23g says so.
  • F3 — nit: the kind is read in the resolver, not on key admission's USER_BAN_FIELDS read as ADR-0151's O3 paragraph sketched; equivalent admitted set, sound reason, worth one sentence in PR-3's body.
  • F4 — nit: the PR body counts DESTRUCTIVE_CLASS_KEYS as a plugin-security package export; it is a module export (the root exports only PermissionEvaluator from that module). The placeholder-id branch of isAgentAccount mirrors the sibling and has no effect.
  • F5 — nit: the access explainer attributes an account's restricted floor as a baseline (the dev's own out-of-scope note); wording only.

No must-fix. F1 earns a delta review of its fix on the next head.

Implemented-by: claude/project-thread-ffxuzs
Reviewed-by: session_01ADHkmPF1skfu2kx89BEXZi

VERDICT: PASS

…h an optional context

Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58
Co-authored-by: Claude <noreply@anthropic.com>
…ission set by name

Standing is still read by name off an envelope's permissions in places: the
approvals override arms, the admin_rescue administrators' slate and the access
explainer's tenant-admin read. The agent ceiling clears a set's bits, not its
name, so the resolver now withholds admin_full_access and every organization
administrator set from an account's permissions, beside its system
permissions. A person's grants are unchanged.

Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1166ca5fe95d6a8d65901dc56e6b84bf3dc5777b
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/resolve-authz-context.agent-account.test.ts, packages/plugins/plugin-approvals/src/agent-account-not-an-administrator.integration.test.ts, packages/runtime/src/automation-connector-connection-door.test.ts, packages/runtime/src/security/resolve-execution-context.agent-account.test.ts, .changeset/31739-ai-service-account-ceiling.md + none (every other path unchanged since 0c2ef42)

Delta review of record 6735749732 (head 0c2ef42, PASS, F1 should-fix). Three commits, no merge, no rebase: a415adc (runtime test reads executionContext?.onBehalfOf, a type fix for check:test-typecheck), e26e44a (F1), 1166ca5 (the new approvals test reads pending_approvers as optional, a type fix).

① Derived judgments

  1. F1 closed at the root. In resolveUserAuthzGrants, the agentAccount arm now also filters ADMIN_FULL_ACCESS and every ORGANIZATION_ADMIN_GRANTS name off grants.permissions, beside the existing systemPermissions zeroing. It runs after §6b's name collection and after the configured-administrator arm (already gated !agentAccount), and before the envelope is built, so neither isTenantAdmin nor the ai_seat arm nor any later reader sees those names. Every name-based standing reader on permissions (approvals isOverrideActor both arms, the admin_rescue slate through holdsTenantAdminRung over resolveUserAuthzGrants, the explainer's posture-less derivePosture) is reached by the one filter. Right.
  2. Person paths are unchanged. The filter sits inside if (agentAccount). The CONTROL in the new approvals test (a person holding the same grants is on the slate and overrides) and the existing person pins stay green. Right.
  3. Pins. The core resolver pin covers every rung name. agent-account-not-an-administrator.integration.test.ts runs a real ApprovalService on ObjectQL + SQLite with envelopes from the real resolver: an account is not on the admin_rescue slate and cannot decide a staffed request (REQUEST_NOT_FOUND, left pending). Ablation H1 (filter off) turns core, approvals and the runtime rig red, then restored. The forced fixture edits (the account fixtures that asserted the administrative names stay) are named in the PR body. Right.
  4. The two type fixes change no assertion. Right.

② Semver level

Unchanged: patch on the same ten packages, Clause-②: no (narrowing), Metadata-protocol: none. The changeset sentence now says the account never carries admin_full_access or an organization administrator set at all, which is what the tree does. Withholding the names is inside the declared narrowing ("never a platform or organization administrator, whatever it holds"), not a new one. Right.

③ Boundary flags

  • Stored-row readers F1 does not reach (from the thread's grep). plugin-auth last-admin-guard.ts counts a stored admin_full_access grant row as an administrator, and plugin-security upgrade-report-delivery.ts ADMINISTRATOR_SETS picks report recipients from stored rows. Both read rows, not an envelope. A non-system write of such a row against an account is refused by this PR's assignment refusal, and no account can exist before PR-3. Ruled: PR-3 carries both (an account row is never counted as the last administrator, and never a report recipient), with pins. Not a blocker here.
  • Rulings recorded on the card thread: 4a = B (decision card security: may an AI service account trigger a flow declared runAs system? (ADR-0151 non-goal, #31739 follow-up) #31874); 4b = B; docs ship with PR-3's door; the ADR-0151 status line goes to a separate amendment card.

④ Design judgment

Withholding the names in the resolver, rather than teaching each reader to read posture, is the root fix: a future reader that judges standing by name gets the same answer for free. A cost to note: an account that is somehow bound to admin_full_access now loses that set's non-breaching object grants too, not just its bits. That is the intended reading of "an administrative grant is a person's to hold". The dogfood battery was not rerun on the delta. That is acceptable: the change is inside if (agentAccount) and dogfood seeds no agent row. The queue's sharded dogfood leg still runs it. No new finding.

Implemented-by: claude/project-thread-ffxuzs
Reviewed-by: session_01ADHkmPF1skfu2kx89BEXZi

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 946a4d2bbcae5a8a882110541b4e9055258e834e
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/index.ts, packages/mcp/src/plugin.ts, packages/runtime/src/domains/automation.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/plugin.ts (the files both this PR and the merged main range touch) + packages/plugins/plugin-security/src/security-plugin-permission-sets.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts (read on the merged head, not changed by the merge for this PR's lines)

Delta review of record 6736879466 (head 1166ca5, PASS). 946a4d2 is the seat's pr_update_branch merge of origin/main 1a66fcf onto 1166ca5 (K was 25), merged by the platform with no conflict and no rebase.

① Derived judgments

The main range touches six files this PR also touches, and none of its hunks overlap this PR's lines:

② Semver level

Unchanged: patch on the same ten packages, Clause-②: no (narrowing), Metadata-protocol: none.

③ Boundary flags

None new. The stored-row readers (last-admin-guard.ts, upgrade-report-delivery.ts) stay ruled to PR-3.

④ Design judgment

No interaction between the merged range and the ceiling: E3 narrows which body a name reads, and the account's ceiling acts on whatever body is read. CI on this head decides the rest (Test Core, the plugin-security and runtime suites, the queue's sharded dogfood). No new finding.

Implemented-by: claude/project-thread-ffxuzs
Reviewed-by: session_01ADHkmPF1skfu2kx89BEXZi

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1166ca5fe95d6a8d65901dc56e6b84bf3dc5777b
Local-runs: none
Scope: packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/resolve-authz-context.agent-account.test.ts, packages/plugins/plugin-approvals/src/agent-account-not-an-administrator.integration.test.ts, packages/runtime/src/automation-connector-connection-door.test.ts, packages/runtime/src/security/resolve-execution-context.agent-account.test.ts, .changeset/31739-ai-service-account-ceiling.md + none (every other path unchanged since 0c2ef42)

Delta review of record 6735749732 (head 0c2ef42, PASS, F1 should-fix). Three commits, no merge, no rebase: a415adc (runtime test reads executionContext?.onBehalfOf, a type fix for check:test-typecheck), e26e44a (F1), 1166ca5 (the new approvals test reads pending_approvers as optional, a type fix).

① Derived judgments

  1. F1 closed at the root. In resolveUserAuthzGrants, the agentAccount arm now also filters ADMIN_FULL_ACCESS and every ORGANIZATION_ADMIN_GRANTS name off grants.permissions, beside the existing systemPermissions zeroing. It runs after §6b's name collection and after the configured-administrator arm (already gated !agentAccount), and before the envelope is built, so neither isTenantAdmin nor the ai_seat arm nor any later reader sees those names. Every name-based standing reader on permissions (approvals isOverrideActor both arms, the admin_rescue slate through holdsTenantAdminRung over resolveUserAuthzGrants, the explainer's posture-less derivePosture) is reached by the one filter. Right.
  2. Person paths are unchanged. The filter sits inside if (agentAccount). The CONTROL in the new approvals test (a person holding the same grants is on the slate and overrides) and the existing person pins stay green. Right.
  3. Pins. The core resolver pin covers every rung name. agent-account-not-an-administrator.integration.test.ts runs a real ApprovalService on ObjectQL + SQLite with envelopes from the real resolver: an account is not on the admin_rescue slate and cannot decide a staffed request (REQUEST_NOT_FOUND, left pending). Ablation H1 (filter off) turns core, approvals and the runtime rig red, then restored. The forced fixture edits (the account fixtures that asserted the administrative names stay) are named in the PR body. Right.
  4. The two type fixes change no assertion. Right.

② Semver level

Unchanged: patch on the same ten packages, Clause-②: no (narrowing), Metadata-protocol: none. The changeset sentence now says the account never carries admin_full_access or an organization administrator set at all, which is what the tree does. Withholding the names is inside the declared narrowing ("never a platform or organization administrator, whatever it holds"), not a new one. Right.

③ Boundary flags

  • Stored-row readers F1 does not reach (from the thread's grep). plugin-auth last-admin-guard.ts counts a stored admin_full_access grant row as an administrator, and plugin-security upgrade-report-delivery.ts ADMINISTRATOR_SETS picks report recipients from stored rows. Both read rows, not an envelope. A non-system write of such a row against an account is refused by this PR's assignment refusal, and no account can exist before PR-3. Ruled: PR-3 carries both (an account row is never counted as the last administrator, and never a report recipient), with pins. Not a blocker here.
  • Rulings recorded on the card thread: 4a = B (decision card security: may an AI service account trigger a flow declared runAs system? (ADR-0151 non-goal, #31739 follow-up) #31874); 4b = B; docs ship with PR-3's door; the ADR-0151 status line goes to a separate amendment card.

④ Design judgment

Withholding the names in the resolver, rather than teaching each reader to read posture, is the root fix: a future reader that judges standing by name gets the same answer for free. A cost to note: an account that is somehow bound to admin_full_access now loses that set's non-breaching object grants too, not just its bits. That is the intended reading of "an administrative grant is a person's to hold". The dogfood battery was not rerun on the delta. That is acceptable: the change is inside if (agentAccount) and dogfood seeds no agent row. The queue's sharded dogfood leg still runs it. No new finding.

Implemented-by: claude/project-thread-ffxuzs
Reviewed-by: session_01ADHkmPF1skfu2kx89BEXZi

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1a141938af8791a9c67908a55a8f23e2636d2e76
Local-runs: none
Scope: none of this PR's paths moved in the merge + read for interaction: packages/core/src/security/effective-object-permissions.ts (ef4af73)

Delta review of record 6736920285 (head 946a4d2, PASS). 1a14193 merges origin/main ac8fede onto 946a4d2 with no rebase and no conflict. The card thread pushed it because the relay's update-branch merge (946a4d2) triggered no workflows.

① Derived judgments

The merged range is four commits: #31883 (app-shell), #31878 (service-analytics), #31859 (spec ActionSchema.preflight) and #31877 (core recycle-bin annotation). None touches a path of this PR. #31877 changes how effective-object-permissions.ts annotates restore and purge for platform objects. It does not touch DESTRUCTIVE_OPERATIONS or OPERATION_TO_PERMISSION, from which the ceiling's DESTRUCTIVE_CLASS_KEYS is derived, so the clamp clears the same keys. Right.

② Semver level

Unchanged: patch on the same ten packages, Clause-②: no (narrowing), Metadata-protocol: none.

③ Boundary flags

None new.

④ Design judgment

No interaction. The thread's pre-push runs on this head: full build 81/81, core 3337, plugin-security 6072, and check:test-typecheck on all 11 touched packages. CI decides the rest.

Implemented-by: claude/project-thread-ffxuzs
Reviewed-by: session_01ADHkmPF1skfu2kx89BEXZi

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 11:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 9, 2026 11:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 5a8dbc7 Nov 9, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ffxuzs branch November 9, 2026 12:18
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…mod R10 keeps a module's other engine.js names) (objectstack-ai#31922)

<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9KX9KQPGPGgUsuSjbwJeH77)_

Refs objectstack-ai#30020

Clause-②: no
Metadata-protocol: none

## 维护者速读

- **改了什么**:把查询引擎 `ObjectQL` 里 `insert`(新建记录)的方法体原封不动地搬进同一节的 facet 模块
`engine-insert.ts`,类上只留一行转调,并删掉门禁登记表里 `insert` 这一行例外。搬移工具 codemod
的配套改动已先由 objectstack-ai#31927 单独落地。
- **为什么改**:objectstack-ai#30020 拆分大文件 `engine.ts`
的既定顺序第六步(E5)。由工具确定性生成,不手搬;工具缺的能力(`./engine.js` 导入行带其他名字时的改道)在 objectstack-ai#31927
里补齐。
- **风险与回滚**:纯搬移:发布的 `.d.ts` 逐字节不变,同样的语句按同样顺序执行;undo 证明逐字节还原
base。所有新建路径(REST、流程、导入、种子)都经过它,dogfood 全量已跑绿。回滚即整体 revert。
- **先看**:① `packages/objectql/src/engine-insert.ts` 文件头的 import 对和 host
接口 ② 三处被迫跟着改路径的地方(见下文 Forced edits)。
- **两分钟验证**:`node scripts/codemod-split-engine.mjs --members --verify
--base $(git merge-base origin/main HEAD)`,预期打印 `undoing the moves does
rebuild the base engine.ts and its modules byte-for-byte`,退出码 0。
- **需要你做的**:无,席位落地。

## What changes

- **`ObjectQL#insert` moves into its facet**
(`packages/objectql/src/engine.ts` → `engine-insert.ts`), generated by
`node scripts/codemod-split-engine.mjs --members insert --write` on
`main` `630a0baa`, then `main` `a15b544d` merged in (merge commit, no
conflict). The stub keeps its docblock and signature. The bare `this`
the body hands on reads `this.facade` (R1): `ql: this.facade` in the
hook context and the ownership-queue slot read. The body's one static
read, `ObjectQL.homeAmountOrganizationOf`, goes through the existing
`ObjectQLStatics` forwarder: R10 puts `engine-insert.ts` on the statics
route. `engine.ts` 10018 → 8692 lines; 13 import bindings only the moved
body read are pruned from it. The after-abort re-entry
(`this.inAfterAbortUnit(() => this.insert(...))`) moves with the body;
the swallow census has no `insert` control, and
`check:swallow-census-controls` stays green.
- **Codemod capability: landed separately as objectstack-ai#31927** (seat ruling:
split it out to keep this PR under the 3000-line human-merge line). R10
keeps a module's other `./engine.js` names beside the statics pair; R4
into a routed module passes a name the module already imports. That is
what lets `--members insert` move this body; main `758c0b45` was merged
into this branch after objectstack-ai#31927 landed, so the codemod diff drops out of
this PR.
- **`scripts/facade-delegation-registry.json`**: the `ObjectQL` ·
`insert` row (objectstack-ai#30020, bare-this) leaves; no other row moves.
`check:facade-delegation-only` 8 → 7 named exceptions.
- **Changeset** `.changeset/30020-objectql-insert-facet.md`,
`@objectstack/objectql` patch, as for E2/S1/E4. The `.d.ts` is unchanged
but the published JS changes (positive control below).

## Forced edits (seat ruling: forced edits only, path re-points, nothing
else)

Each one is a gate or test that reads `insert`'s location from source
and went red after the move:

| File | Required by | Red on the move | Edit |
|:--|:--|:--|:--|
| `packages/objectql/src/validate-preview-parent.test.ts` | objectql
suite, `objectstack-ai#22474 (f) — parity` | `expected 3 to be greater than or equal
to 4` (it parses the write-side `evaluateValidationRules` calls out of
`engine.ts`; two were `insert`'s) | `engine-insert.ts` joins the file
list it reads, as objectstack-ai#25522 cut 16 added `engine-validate.ts`; comment
updated; no assertion changed |
| `content/docs/permissions/system-context.mdx` | `pnpm
check:system-context-census` | `[register-drift]` and
`[anchor-is-not-a-read-site] the page anchors
packages/objectql/src/engine.ts#insert` | the generated register row
(`--write`) and one row's anchor cell: `engine.ts#insert` →
`engine-insert.ts#insert` (the 3 elevation reads moved with the body;
same counts, same row keys 21, 22) |
| `scripts/check-system-context-census.mjs` | `pnpm
check:system-context-census` | `[ledger-stale-collapse] NON_READ_ANCHORS
row for packages/objectql/src/engine.ts#insert` | that row's `file` →
`engine-insert.ts`; symbol and `why` unchanged |

Overlap note: open objectstack-ai#31867 also edits `system-context.mdx` (its row 23g
and two new register rows). The hunks are different, and `git
merge-tree` of this head with objectstack-ai#31867's head is clean. The register
region is `merge=os-regen`.

## Tests

- The codemod's self-test cases and its four ablations ship with objectstack-ai#31927.
This PR adds no test. Its one test edit is the forced parity-reader
re-point above.

## Verification

All heavy readings went through `scripts/pm/os-verify-lock.sh`, each
with `VERDICT command-exit 0`. Base is `main` `a15b544d`; head is
`5b489a9e`, unless the row says otherwise. Re-measured after merging
main `758c0b45` (objectstack-ai#31927 landed): head `7f755abc`.

| Run | Base | Head |
|:--|:--|:--|
| `node scripts/codemod-split-engine.mjs --members --verify --base
origin/main` (a15b544) | — | exit 0; 1 moved (`insert`), byte-exact, 13
bindings pruned |
| codemod `--self-test` (head `7f755abc`, the codemod as objectstack-ai#31927 landed
it) | — | 232 cases / 14 batteries |
| `pnpm check:facade-delegation-only` | 8 exceptions | 7 exceptions |
| `pnpm --filter @objectstack/objectql typecheck` | — | exit 0 |
| `pnpm --filter @objectstack/objectql test` | 580 files / 11944 tests |
580 files / 11944 tests; on `7f755abc`: 581 files / 11956 tests (main's
own additions) |
| objectql `dist` `.d.ts` (`core`, `index`, `util-*`, `.d.ts` +
`.d.mts`) | — | byte-identical to base (`cmp`) |
| JS positive control: `InsertFacet.insert.call` in `dist/index.mjs` | 0
| 1 |
| `pnpm --filter @objectstack/dogfood test` (full, no shard) | — | 442
passed, 3 skipped files / 3669 passed, 42 skipped tests |
| `eslint --no-inline-config` on `engine.ts`, `engine-insert.ts`
(objectql has no `lint` script) | — | exit 0 |
| `node scripts/pm/check-governed-merges.mjs --pr 31922` (head
`7f755abc`) | — | 7 files, 2969 changed lines (+1552 / −1417) ≤ 3000;
not governed |

<details><summary>Derived gates: 120 / 120 on head 5b489a9 and again on
7f755ab, reconciled</summary>

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `5b489a9e` derived 120 commands. The docs
page puts the docs families in scope, so the count is up from 81 before
the forced edits. All 120 ran after a full `pnpm build`, each exit 0.
`--ran`: "120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED
(a DERIVED zero)". On the first round (head `55ce44da`, 81 derived) one
gate was red, `check:system-context-census`, and so was the objectql
parity test. The forced edits above fixed both. Census, swallow-census
controls and the codemod self-test were re-run after the `main` merge.
After `758c0b45` was merged in, 120 were derived at `7f755abc`, all 120
ran after a full build, each exit 0, and `--ran` reports 0 NOT-MEASURED
and 0 UNRUN.

</details>

`test:repo` was not run locally (no diff in that project); CI's Test
Core carries it.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmNmrFUKGH6LP8Jj8ZV)_

<!-- signed: domain:server#1 · os-steve ·
session_01ADHkmPF1skfu2kx89BEXZi -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…m by its namespace or _env, and ?package= is the exact qualifier (objectstack-ai#31959)

<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Ff4UJdxHJzwSQG7VEko8ou)_

Part of objectstack-ai#31691

ADR-0150 child G1, part (a) of three (seat ruling 2026-11-09T12:42Z: (a)
`/meta`, (b) `/actions`, (c) automation, hooks and connectors; Q1, Q2
and Q3 all A). G2 (objectstack-ai#31692, PR objectstack-ai#31955) has merged; part (b) reuses its
namespace→package mapping. Claim by `domain:server#1`, card-thread
session_01ADHkmQv69sQL5nfT8xuPWs.

Clause-②: no (narrowing). Measured by the base arms below and by
ablation. `?package=X` no longer serves another package's item, the
runtime's bare read of a two-holder name no longer falls through to the
by-name service read, the activation door refuses where it answered 503
or picked the first rule, and the running install door refuses six
namespaces it accepted (a package the boot registers under one still
boots, warned once). The only new surface is the qualified read path,
which answered 404 on base.
Metadata-protocol: narrowed. The running install door now refuses a
namespace value (`actions`, `connectors`, `global`, `hooks`, `oauth`,
`trigger`) that an author could write before. Nothing else an author
writes changes. (Step 0 estimated `none` for the read path. The install
refusal is a refused value, so the line is `narrowed` and the changeset
is `minor`.)

> [!WARNING]
> **BREAKING**, four narrowings. First-party census: 0 in the four
example apps and every first-party manifest (no namespace is one of the
six literals; no first-party read sends `?package=` for a name the
package does not hold). Studio's pair reads (`?package=<app>.custom`)
keep their answer and are pinned.
> 1. `?package=X` on `GET /meta/:type/:name`, for E8's nine types,
answers X's item or `409 RESOURCE_CONFLICT` naming every holder. Base
served the first-registered package's item.
> 2. The runtime dispatcher's bare read of a two-holder name answers the
protocol's `409`. Base fell through to the by-name metadata service read
and served its first match. REST already answered 409.
> 3. `POST /security/_activation/:type/:name` for a sharing rule, queue
or assignment rule two packages declare answers `409 RESOURCE_CONFLICT`
with every candidate in `details.candidates`, and writes nothing. Base
answered 503 for sharing rule and queue, and switched off the
first-listed assignment rule (200).
> 4. A package whose namespace is one of the six address literals is
refused `422 NAMESPACE_CONFLICT` at the running install door (a
marketplace or API install after the boot), as is an upgrade that moves
a namespace onto one. A package the boot registers under one, already
installed or shipped in code, boots with one warning and is read by
`?package=<id>`; so does a reload or upgrade that keeps its namespace.

<!-- adr-0087: not-required (no-migration-prescription) — carried in the
changeset -->

This is a narrowing, so the review of record runs at
CONTRACT_REVIEW_TIER.

## 维护者速读

- 改了什么:`/meta` 按名读取支持 D5 的限定地址:`GET /meta/:type/:ns/:name`(已安装命名空间)和
`GET /meta/:type/_env/:name`(环境自己的项),两个传输面(REST 和运行时分发器)都有。`?package=X`
变成精确限定:X 不持有该名就 409 列出所有持有者。安装门拒绝拼成路由字面量的命名空间。激活门遇到两个包同名的声明改答 409
并列出全部候选。
- 为什么改:ADR-0150 D5 要求有歧义的按名地址点名而不是猜。之前 `?package=X`
会悄悄返回别的包的项,运行时还会绕过协议的拒绝。
- 风险与回滚:只收窄,第一方 0 处受影响。控制台对两持有者名的裸读会收到 409 并当作不存在(ui 车道另开读取卡)。回滚即
revert。
- 先看:`protocol-exact-qualifier.ts`(精确限定和地址解析的唯一实现),`domains/meta.ts`
顶部的改写段,`meta-qualified-item-route.ts`。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/meta-qualified-item-read.test.ts`,预期 13 passed。
- 需要你做的:无,席位复审后落地。D5 的子资源字面量优先规则(Q1)留给 ADR 修订卡,见文末。

## What changes

- **`packages/metadata-protocol/src/protocol-exact-qualifier.ts`**
(new). One implementation for both transports:
- `admitExactQualifier`: for E8's nine types
(`registry.readsByPackage`), a read naming package X is admitted when X
holds the name, or X is a customization pair of a holder (`pairsOf`), or
a stored row bound to X carries it (active or draft). Otherwise it
throws `PackageScopedNameRefusedError`, new reason
`not-held-by-qualifier`, naming every holder and the environment's own
item. With no holder anywhere, the read answers its own miss.
- `exactQualifierItem`: when the layer below (the registry's
prefer-local read) answered another installed package's item, it serves
the admitted holder's item instead. Two pair bases that both hold the
name are refused `ambiguous-in-closure`.
- `resolveMetaItemAddress`: reads the middle segment in D5's order
(`_env`, then an installed namespace via `getNamespaceOwners`, else
nothing). A namespace two packages co-own names the owner that holds the
name. When more than one of its owners holds the name (a shareable
namespace, `base`, `system` or `sys`, is exempt from the one-holder
install rule, D2), it throws `PackageScopedNameRefusedError`, new reason
`ambiguous-in-namespace`, naming each holder: `409 RESOURCE_CONFLICT` on
both transports, never a pick (contract review must-fix, fixed in
40a88d8).
- `admitEnvironmentQualifier`: `_env` answers the environment's own
item, or refuses naming the package holders.
- `META_ITEM_SUBRESOURCE_SEGMENTS`: the literals served after `:name` on
either transport (Q1). `resolveMetaItemAddress` answers no address when
the last segment is one of them, so both transports read the rule from
one place.
- **`protocol-meta-item-reads.ts`**, **`protocol.ts`**: the item read
calls the two exact-qualifier steps; the protocol exposes
`resolveMetaItemAddress` and `admitEnvironmentQualifier` (on the
canonical-fold roster).
- **`packages/objectql/src/registry.ts`** (`installPackage`): the
address-literal namespace refusal (`validateNamespaceNotAddressLiteral`,
`422 NAMESPACE_CONFLICT`) answers at the running install door only, i.e.
a marketplace or API install after the boot, and an upgrade that moves
an installed package's namespace onto a literal. A package the boot
registers under such a namespace (already installed, or shipped in code:
every registration before the cold boot seals the holders), or a reload
or upgrade of it that keeps its namespace, boots as before with one
warning naming the package and its `?package=<id>` read (epic red-line
R1, fixed in 51c58a5 and dfb9174). No upgrade-report findings seam
exists yet; the seat's follow-up card names these there and adds a
build-time refusal.
- **`packages/core/src/package-scoped-resolution.ts`**: reasons
`not-held-by-qualifier` and `ambiguous-in-namespace` and their
sentences.
- **`packages/rest/src/meta-qualified-item-route.ts`** (new): `GET
/api/v1/meta/:type/:ns/:name`, registered after every other `/meta`
route so each literal three-segment sibling wins. It answers through the
item read's own handler (`registerMetaItemReadRoutes` now returns it),
never a copy. A path that is no address answers what the host answered
before the route existed, `404 ENDPOINT_NOT_FOUND`. It reaches the
protocol as a service and imports nothing from metadata-protocol.
- **`packages/runtime/src/domains/meta.ts`**:
- The qualified path is rewritten once at domain entry to the item read,
before every gate (Q3's rule, applied here).
- An anonymous caller is never rewritten, so the anonymous deny judges
the path as REST's does (REST's public-audience exemption names no
qualified shape).
- A package-scoped refusal from `getMetaItem` is rethrown instead of
falling through to the metadata service.
- A refusal from reading the address itself answers `409` through
`errorFromThrown`.
- A caller the type-level read admission refuses (`metaTypeReadRefusal`,
objectstack-ai#21087) is never read as an address: the path stays three segments and
the admission answers `403 PERMISSION_DENIED`, as `RestServer`'s guarded
registrar does before any `/meta` handler. So a refused caller never
learns that a name exists or who holds it, and the two-qualifier `400`
sits behind the same gate on both transports (contract delta review
should-fix, fixed in 9297723).
- **`packages/runtime/src/domains/catalog-activation.ts`**: a thrown
package-scoped refusal answers `409 RESOURCE_CONFLICT` with the
resolver's sentence and `candidates`, the shape `/actions` uses. The
assignment-rule lookup refuses two declaring packages instead of taking
the first.
- **`packages/objectql/src/registry.ts`**: `installPackage` refuses a
namespace that `validateNamespaceNotAddressLiteral` (spec/kernel, P)
refuses, `NamespaceAddressLiteralError` (`NAMESPACE_CONFLICT`, 422),
before K's object-name check.
- **Ledgers**: `GET /meta/:type/:ns/:name` in runtime `route-ledger.ts`
and `GET /api/v1/meta/:type/:ns/:name` in `rest-route-ledger.ts`, both
`server-only` (the SDK reaches the same item by `meta.getItem(type,
name, { packageId })`). `route-ledger.conformance.test.ts` pins
`NAMESPACE_POSITION_LITERALS` to the literal segments the ledger serves
in each door's namespace position, both directions.
`rest-config-mount-table.pin.test.ts` learns the route.
- **`.changeset/31691-meta-qualified-address.md`**: `minor` for core,
objectql, metadata-protocol, rest and runtime; both declaration lines,
the BREAKING list, the 405 note, FROM → TO fixes, the ADR-0087 marker.
- **`scripts/engine-double-contract.pinned.json`**: the ledger learns
the two new tests' engine doubles (`--write`). **`route-ledger.ts`**:
the census sentence (`--fix`).

Forced test edits: `meta-type-read-capability.test.ts` (its protocol
double learns `resolveMetaItemAddress`, the driver fills `:ns`, and a
module-top import), `rest-config-mount-table.pin.test.ts` (the new
route), the canonical-fold roster (two verbs and a module-top import),
and one existing dogfood assertion: `showcase-anonymous-deny-surfaces`
pins the retired compound save as 405 `Allow: GET, HEAD` instead of 404
(see Verification).

## Step 0 (measured on `main` 80c4c9a, report
`/mnt/project-files/server-seat1/31691-step0.md`)

Door map, size estimate (≈4,200 lines, so three parts), ledgers and
overlap (none) are in the step-0 report. The seat ruled three parts,
each landing with its own contract review.

## ADR-0150's four readings

- **(a) the phase-1 one-holder tests stay green**:
`bare-name-one-holder.ts` is untouched and its tests run in the objectql
suite.
- **(b) bare-name behaviour unchanged for one-holder names**: CONTROLs
on each door: a one-holder name read bare and by `?package=` of its
holder; a type outside the nine (view) keeps the prefer-local read; a
read naming no package is E8's, unchanged; a middle segment that is no
installed namespace answers 404 as before; a sub-resource literal keeps
its route; the activation door with one declaring package answers as
before; namespaces `triggers` and `crm_hooks` install.
- **(c) no migration**: nothing stored changes shape. A refused
namespace is the author's to rename.
- **(d)** `Clause-②: no (narrowing)`, `Metadata-protocol: narrowed`,
`adr-0087: not-required (no-migration-prescription)`.

## Carry-ins

1. **Pair reads keep their answer** (ui census note): `?package=PAIR` of
a base-held item serves the base's item (also when another package
registered the name first), the pair's declared override row is served
at the pair, and the pair's added member is served at the pair. Pinned
in `package-scoped-exact-qualifier.test.ts`.
2. **The 409 face for Console**: a bare Console read of a name two
packages hold (E8's nine types) already answered 409 on REST; this PR
makes the runtime dispatcher answer it too. Console treats that 409 as
absent. The ui lane files the reader card; this PR changes nothing under
`ui/`.
3. **Activation door**: `409 RESOURCE_CONFLICT` with every candidate in
`details.candidates`, pinned for sharing rule, queue and assignment
rule. Base arms measured on f303690: sharing rule and queue 503
`SERVICE_UNAVAILABLE`, assignment rule 200 (first match switched). The
two-holder permission set or position is not reachable: `registerItem`
refuses it with `SecurityCatalogNameConflictError`.

## Verification

Head dfb9174 (main merged at 13e935c and again at 9a7bdd7). Every
heavy run went under `os-verify-lock`.

- **New tests**: install door 10
(`registry-namespace-address-literal.test.ts`); exact qualifier 14
(`package-scoped-exact-qualifier.test.ts`); runtime qualified read 17
(`meta-qualified-item-read.test.ts`, real dispatcher, protocol and
registry, including anonymous parity); REST qualified route 14
(`meta-qualified-item-route.test.ts`, real route table, including the
route order and the anonymous classification). Both of the last two
carry the shared-namespace pair: two `sys` owners holding `page/home`
answer 409 naming both; one holder is served; with two `sys` holders of
a datasource name, a caller refused the type answers 403
`PERMISSION_DENIED` naming neither, and an admitted caller (CONTROL) the
409; activation 9 (`catalog-activation-package-scoped.test.ts`); the
grammar ↔ ledger pin.
- **Base arms**: the qualified path answered 404 on both transports (the
dispatcher's `ROUTE_NOT_FOUND`, the REST host's unmatched
`ENDPOINT_NOT_FOUND`), and still does where the middle segment is no
address; `?package=gamma` served alpha's page; activation as above.
- **Suites** (each `command-exit 0`, on the head): core 149 files / 3345
passed; objectql 584 / 11985 passed; metadata-protocol 331 (+5 skipped)
/ 22641 passed (29 skipped); rest 404 / 7956 passed (355 skipped);
runtime 574 / 10570 passed (19 skipped). Rerun on dfb9174 (after the
main merge and R1): core 149 / 3350; objectql 584 / 11988;
metadata-protocol 331 (+5 skipped) / 22641 (29 skipped); rest 404 / 7958
(355 skipped); runtime 575 / 10592 (19 skipped); all exit 0. `tsc
--noEmit` clean for core, objectql, metadata-protocol, rest and runtime
on that head. **The whole dogfood package** before the gate-fix round:
441 (+3 skipped) / 3668 passed (42 skipped) with 1 red,
`showcase-anonymous-deny-surfaces` (see below); that file and
`route-ledger-live-mount-parity` rerun on the head: 96 passed.
`packages/qa/http-conformance`: 20 / 275 passed. The runtime suite's
`resolve-execution-context.agent-account` red seen once was a stale
`plugin-security` dist (built before objectstack-ai#31867's source); rebuilt, 7
passed.
- **The dogfood red, and the one forced dogfood edit**: `PUT
/meta/object/views/<name>` (the retired compound save) answered 404 and
now answers `405 METHOD_NOT_ALLOWED`, `Allow: GET, HEAD`. The adapter's
unmatched sink answers 405 when the path matches a route under another
verb, and `/meta/:type/:x/:y` now has the qualified GET. No PUT is
routed and no auth floor speaks, for anonymous and member alike; the pin
now asserts that (405, `METHOD_NOT_ALLOWED`, `allowed: [GET, HEAD]`) and
the changeset names it.
- **Typecheck and lint**: `tsc --noEmit` passes for core, objectql,
metadata-protocol, rest, runtime on the head, and the dogfood package on
the first commit. `eslint --no-inline-config` passes on every changed
file.
- **Gates**: `dispatch-gates --commands` derived 93 families on the head
(40a88d8, 9297723, and again on dfb9174); all 93 ran, exit 0 each
time. `--ran`: 93 run, 0 NOT-MEASURED, 0 UNRUN. The first round (86
families, before `scripts/` was touched) surfaced 10 findings, all fixed
in the second commit: `check:error-code-provenance` and
`check:route-envelope` (the REST route now answers through `sendError`,
its no-address 404 the host's own `ENDPOINT_NOT_FOUND`),
`check:undeclared-dep-imports` (rest no longer imports
metadata-protocol: the sub-resource rule moved into
`resolveMetaItemAddress`), `check:facade-delegation-only` (the protocol
members forward in one call; the type folds in the module),
`check:engine-double-contract` (ledger `--write`),
`check:objectql-double-limit` and `check:where-matcher` (the new doubles
hold `limit` and refuse operators), `check:test-source-alias`
(module-top imports), `check:doc-authoring` (tracker ids out of the
ledger notes) and `check:route-ledger-census` (`--fix`).
`check:type-check-debt`: green.

<details><summary>Ablations (each file restored, tree checked
unchanged)</summary>

Run on the first commit (6b3f206); the second commit moves no guarded
line except the sub-resource check, which A9 still covers.

1. `admitExactQualifier` off (returns `undefined`) → **5 of 13 red** in
`package-scoped-exact-qualifier.test.ts`: every narrowing case.
2. `exactQualifierItem` off → **1 of 14 red**: the pair read where
another package registered the name first serves beta's dashboard.
3. `admitEnvironmentQualifier` off → **1 of 13 red** in the runtime
test: `_env` for a name only packages hold.
4. Runtime rethrow off (`isPackageScopedRefusal(e) && false`) → **3 of
13 red**: the bare two-holder read falls through to the service read.
5. Runtime anonymous rewrite (drop `!anonymous`) → **2 of 13 red**: the
`?package=` case answers 400 before the deny, and `/book/alpha/guide`
takes the public-audience exemption.
6. Install literal gate off → **7 of 10 red**: the six literals and the
refusal shape; both CONTROLs stay green.
7. Activation `packageScopedRefusalOf` → `undefined` → **3 of 9 red**:
sharing rule and queue back to 503, assignment rule back to the first
match.
8. A fake ledger row `GET /meta/:type/history` → **1 red** in
`route-ledger.conformance.test.ts`: the grammar pin.
9. REST route not registered → **6 of 10 red** in
`meta-qualified-item-route.test.ts`.
10. REST `_env` admit off → **1 of 10 red**.
11. (On 40a88d8) the `ambiguous-in-namespace` refusal off (take the
first holder) → **1 red** in each of the REST (1 of 12) and runtime (1
of 15) tests: the two-holder `sys` read serves one of them; the
one-holder control stays green.
12. (On 9297723) the runtime's new admission condition off → **1 of 17
red**: the refused caller answers the 409 naming both holders. The REST
registrar's type gate off → **1 of 14 red**, the same case. Each CONTROL
stays green.
13. (On 51c58a5 / dfb9174) the boot exemption off (`alreadyHeld =
false`) → **2 of 12 red** in
`registry-namespace-address-literal.test.ts` (the boot itself refused)
and **1 of 18** in the runtime test (the `?package=` read of a `hooks`
package); the reload arm off → **1 of 12**; the namespace match widened
back to any registered id → **1 of 13** (the upgrade moving onto `hooks`
installs).

</details>

## Open questions (seat's call)

1. **Q1 rule for the ADR amendment card.** The runtime's reading of D5's
"a literal first": when the last segment of `/meta/:type/:x/:y` is a
served sub-resource literal (`audit`, `diff`, `history`, `layers`,
`publish`, `published`, `references`, `rollback`, `tree`), the
sub-resource route wins, so `/meta/app/crm/history` is the history of
app `crm`; an item named `history` in namespace `crm` is reached by
`?package=`. Both readings are pinned. No spec constant spells this
list; the ADR-0150 amendment card should state it in D5.
2. **`Metadata-protocol: narrowed`** (step 0 said `none` for the read
path). The install refusal refuses a namespace value, which the AGENTS
definition counts as narrowed, so the changeset is `minor`.
3. **405 on the qualified shape.** Non-GET verbs on `/meta/:type/:x/:y`
now answer `405 Allow: GET, HEAD` (adapter behaviour for any path a
route matches under another verb). Recommendation: keep; it is truthful,
and the alternative is registering 404 stubs for each verb.
- **Names** (seat's call): `NamespaceAddressLiteralError`, refusal
reason `not-held-by-qualifier`, `admitExactQualifier`,
`exactQualifierItem`, `resolveMetaItemAddress`,
`admitEnvironmentQualifier`, `META_ITEM_SUBRESOURCE_SEGMENTS`,
`MetaItemAddress`, `registerMetaQualifiedItemRoute`,
`MetaItemReadHandler`. No new error code.
- **Part (b) carry-in** (G2 review, 6743156352 / 6743815854): the
qualified `/actions` arm's object-less fallback is settled in (b), not
here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmQv69sQL5nfT8xuPWs)_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ADHkmQv69sQL5nfT8xuPWs

<!-- signed: domain:server#1 · os-steve ·
session_01ADHkmPF1skfu2kx89BEXZi -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
objectstack-ai#31966)

Part of objectstack-ai#31739

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

Clause-② measured: two new auth doors (`POST
/api/v1/auth/service-account/register`, `POST
/api/v1/auth/service-account/create-key`), two new `sys_user` actions
(`register_service_account`, `create_service_account_key`), and four new
`@objectstack/core` root exports (`readRequestedApiKeyExpiry`,
`signApiKeyExpiry`, `ApiKeyExpiryRefusal`,
`ApiKeyExpiryCeilingRefusal`). No `packages/spec` file, stored column or
published shape changes; no export is removed or retyped. The two
filters (break-glass guard, upgrade-report audience) leave out only rows
of the agent kind, and only this PR's door (or a seed) writes one, so no
existing accept-set narrows. `POST /api/v1/keys` answers exactly as
before (its ceiling code stays the runtime's, ledgered there). Changeset
level `patch` (Metadata-protocol none).

## 维护者速读

- 改了什么:新增两个入口:组织管理员或平台管理员注册 AI 服务账号(`principal_kind = agent` 的
`sys_user`,归属一位负责人,占位邮箱,组织里的普通成员);负责人或管理员为账号签发 API 密钥(绑定账号所在组织,过期上限与
`POST /keys` 同一规则)。`sys_user` 上加了对应的两个按钮。
- 为什么改:ADR-0151 D1、D4,三步拆分的第三步前半(PR-1 objectstack-ai#31779、PR-2 objectstack-ai#31867
已合并);挂起、恢复、换负责人、退役与吊销/恢复密钥在 3b(`Fixes objectstack-ai#31739`)。
- 风险与回滚:两个入口只认登录会话(API 密钥一律
401),所以账号不能注册账号、也不能给自己签密钥;注册的两次写在一个事务里。破窗守卫与升级报告收件人不再把账号算作管理员。回滚即还原本 PR。
-
先看:`packages/plugins/plugin-auth/src/service-account-doors.ts`(权限判断、事务、签发),`auth-plugin.ts`
里的两个挂载,`last-admin-guard.ts#resolveAdminUserIds` 与
`upgrade-report-delivery.ts#withoutAgentAccounts`。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/service-account-doors.test.ts
src/last-admin-guard.agent-account.test.ts`,应见 27 个用例全绿。
- 需要你做的:无,席位落地(CONTRACT_REVIEW_TIER 评审)。

## What changes

- **Register** (`POST /api/v1/auth/service-account/register`, body
`name`, optional `ownerId`). The caller is read from the better-auth
session only. Authority: an organization administrator (owner or admin
grade on an active membership) of the caller's active organization, or a
platform administrator. The owner defaults to the caller and must be an
active member of that organization. One transaction writes the
`sys_user` row (`principal_kind: 'agent'`, `accountable_user_id` the
owner, a generated `@placeholder.invalid` email, `email_verified:
false`, `source: 'env_native'`) through the engine, so PR-1's row guard
judges it, and the vendor's server-only `addMember` as a plain `member`.
A refused membership rolls the row back. `201 { userId, name,
principalKind, organizationId, ownerId }`.
- **Create key** (`POST /api/v1/auth/service-account/create-key`, body
`userId`, optional `name`, `expires_at`). Authority: the account's owner
(while an active member of the account's organization), an administrator
of the account's organization, or a platform administrator. The key's
`active_organization_id` is the account's organization, never the
caller's or the body's. Expiry is signed by the same rule as `POST
/api/v1/keys`, now one function pair in `@objectstack/core` that
`runtime/domains/keys.ts` also calls (no behaviour change there; its 83
tests are unchanged and green). The raw key is returned once; only its
hash is stored, and neither is logged.
- **Refusals** reuse existing codes: `401 UNAUTHENTICATED` (no session,
or an API key only), the held-session `403`, `403 PERMISSION_DENIED`,
`400 VALIDATION_FAILED` (`name` required or too long, `ownerId` not a
member, `expires_at` past the ceiling), `400 VALIDATION_ERROR` (an
`expires_at` that is no date or not in the future), `404
RESOURCE_NOT_FOUND` for an id that is not an account (a person's id
included), `409 RESOURCE_CONFLICT` for an account with no active
membership, `503` when the authority read fails. **Accepted deviation
from `POST /keys`:** the create-key ceiling answers `400
VALIDATION_FAILED` at `expires_at` with `POST /keys`'s message, where
`POST /keys` answers `400 API_KEY_EXPIRY_TOO_LONG`. That code is
ledgered under `@objectstack/runtime`; stamping it from plugin-auth
would need a ledger row in `packages/spec`, which this card does not
edit.
- **The name carries the purpose for now.** There is no purpose column:
one would be a Metadata-protocol change and needs its own card (open
debt on objectstack-ai#31739).
- **The placeholder email** is `<random>@placeholder.invalid`:
`.invalid` is reserved (RFC 2606), so it is never deliverable and never
collides with a real address. Pinned: two accounts get distinct
placeholders, both in that domain.
- **Neither administrator count includes an account.**
`resolveAdminUserIds` (the break-glass guard, both populations) drops
agent rows after enumeration, so an account holding `admin_full_access`
and an owner grade does not let the last person administrator be banned
or lose the grant. The upgrade-report audience and
`auditOwnerOrganization`'s administrator list drop agent rows the same
way.
- **The kind is read in the resolver, not `USER_BAN_FIELDS`** (F3): an
account's standing comes from `principal_kind` on its `sys_user` row in
`resolveUserAuthzGrants` (PR-2), and a ban of the account refuses its
key through the existing ban predicate; the ban field list is unchanged.
- **Actions.** `register_service_account` (list toolbar; visible to
platform admins and organization owners/admins) and
`create_service_account_key` (record; visible on an agent row to
administrators and to its owner), with a result dialog like
`create_api_key`. Labels in en and the 10 shipped locales.
- **Ledgers.** Two rows in `auth-route-ledger.ts` (ObjectStack mount,
server-only) and the conformance list. The tenant-audit census and its
page now count the door's two engine writes (280 → 282).
- **Docs.** `content/docs/ai/agents.mdx` (the AI service account,
unattended track) and `content/docs/permissions/index.mdx` (AI service
accounts).

## Tests

New, all green:
- plugin-auth `service-account-doors.test.ts` (21, real ObjectQL +
SQLite with PR-1's row guard): the authority matrix for both doors; the
written rows; distinct placeholders; owner default and owner rules;
rollback when the membership is refused; 503 on an unreadable authority
read; the key's organization taken from the account; 404 for a person;
409 for an account with no membership; the owner who left; the ceiling;
a past date; `userId` required.
- plugin-auth `last-admin-guard.agent-account.test.ts` (6, real engine):
the enumeration names the person only, for both populations; banning or
revoking the last person administrator is refused although an account
holds the same grant and an owner grade. CONTROLs: a person with the
grant, a person with the owner grade, banning the account itself.
- plugin-security `upgrade-report-delivery.test.ts` (+3): an account is
never a recipient and never an organization's administrator there.
- dogfood `service-account-register-key.dogfood.test.ts` (8, showcase
stack over HTTP): anonymous 401, member 403 with nothing written; admin
registers (rows checked); an API-key caller 401; another member 403 on
create-key; the owner mints; the key is admitted at the data door; the
account's own key mints nothing (401 here, 403 at `POST /keys`); a
person's id 404; ban refuses the key as a made-up key is refused, unban
restores it.

Forced test edits:
- `upgrade-report-delivery.test.ts`: the engine double's `matches`
learns `{ $in: [...] }` (the new by-id read).
- `auth-route-ledger.conformance.test.ts`: the two routes added to the
hand-written `own` list.
- Forced census edit: `content/docs/permissions/tenant-audit-census.mdx`
and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` count
the door's two engine writes (write call sites 280 → 282; the decidable,
undecidable and elevation-undecidable figures move with them).

## Acceptance notes

- Registration, key mint, owner rules and the two administrator counts
land here; the lifecycle doors (suspend, resume, set-owner, retire,
owner-leaves auto-suspend, revoke-key and restore-key) land in 3b, which
closes the card.
- `docs/adr/**` is untouched; the ADR-0151 status amendment is the
seat's.

## Verification

- Suites (under `os-verify-lock`): core 3345, runtime 10545, plugin-auth
4530, plugin-security 6094, platform-objects 1934 passed;
`check:test-typecheck` green in all five.
- Dogfood: whole package under the lock: 443 files passed, 3 skipped;
3677 tests passed, 42 skipped.
- `dispatch-gates --ran`: 109 of 109 derived families run, all exit 0 (a
DERIVED zero). eslint on the changed files: clean.
- Ablations (each restored, `git diff HEAD` empty after): 7 of 7
negative pins go red when their rule is removed:
  - the last-admin filter: 3 red (enumeration, ban, revoke);
  - the upgrade-report filter: 3 red;
  - register authority: 2 red; create-key authority: 2 red;
  - the one transaction: 1 red (rollback);
  - the agent-kind check: 1 red (404 for a person);
  - the membership check: 1 red (409 when retired).
The session-only read has no alternative branch to remove (the doors
call `getSession`, which never resolves an API key); it is pinned from
outside by the dogfood's two `401`s for a key caller.

<!-- signed: domain:server#1 · os-steve ·
session_01ADHkmPK8RUPmLyYjQGCe58 -->

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58

---------

Co-authored-by: Claude <noreply@anthropic.com>
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…, change owner, retire, and one key's revoke and restore (objectstack-ai#31981)

Fixes objectstack-ai#31739

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

Clause-② measured: six new auth doors (`POST
/api/v1/auth/service-account/suspend`, `resume`, `set-owner`, `retire`,
`revoke-key`, `restore-key`) and an engine trigger that suspends an AI
service account when its owner leaves its organization. No
`packages/spec` file, stored column, published shape or package-root
export changes. The trigger acts only on `sys_user` rows of the agent
kind, which only PR-3a's door (or a seed) writes, so no existing
accept-set narrows. Changeset level `patch` (Metadata-protocol none).

## 维护者速读

- 改了什么:AI 服务账号的生命周期入口:负责人或管理员可以挂起(写 ban
状态,所有密钥下次调用即被拒,不吊销)、吊销或恢复某一把密钥;只有管理员能恢复挂起、换负责人、退役(结束成员身份并以
`organization_membership_ended`
吊销全部密钥,保留行)。负责人离开账号所在组织时自动挂起,直到管理员指定新负责人后再恢复。
- 为什么改:ADR-0151 D6,三步拆分的最后一步(PR-1 objectstack-ai#31779、PR-2 objectstack-ai#31867、3a objectstack-ai#31966 已合并),本 PR
关闭 objectstack-ai#31739。
- 风险与回滚:所有入口只认登录会话,API 密钥(包括账号自己的)一律 401,所以账号不能解除自己的挂起,也碰不到自己的密钥。恢复密钥沿用
restore guard 的原因规则。负责人离开触发器是 best-effort,失败只记日志,不影响移除成员本身。回滚即还原本 PR。
-
先看:`packages/plugins/plugin-auth/src/service-account-lifecycle.ts`(权限矩阵与密钥原因规则),`service-account-owner-leaves.ts`(两个
`sys_member` 钩子),`auth-plugin.ts` 里的六个挂载。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/service-account-lifecycle.test.ts`,应见 28 个用例全绿。
- 需要你做的:无,席位落地。

## What changes

Every door reads its caller from the better-auth session only (the same
mount seam as 3a): anonymous and API-key callers get `401`, a held
session gets its posture's `403`. An id that is not an AI service
account (a person's included) is answered `404 RESOURCE_NOT_FOUND`. An
unreadable membership is `503`, never a verdict. Writes are system
writes attributed to the person.

| Door | Who | Writes |
|:--|:--|:--|
| `suspend` `{userId, reason?}` | the owner (while a member of the
account's organization), an administrator of that organization, a
platform administrator | `banned: true`, `ban_expires: null`,
`ban_reason` `AI service account suspended` (followed by `: ` and the
caller's reason, at most 500 characters); a ban already in force stays
exactly as it is |
| `resume` `{userId}` | an administrator only; the owner gets `403` |
clears the ban state; an organization administrator lifts only this
family's suspension (that reason, or the owner-left one), any other ban
(a platform administrator's `/admin/ban-user`) only a platform
administrator (`403` otherwise); `409 RESOURCE_CONFLICT` for a retired
account or one whose owner is not a member of its organization |
| `set-owner` `{userId, ownerId}` | an administrator only |
`accountable_user_id`; the new owner must be a member of the account's
organization (`400 VALIDATION_FAILED` at `ownerId`), and PR-1's row
rules check that it is a person; it does not resume |
| `retire` `{userId}` | an administrator only | in one transaction:
deletes the account's `sys_member` rows, then reads and revokes every
key with `organization_membership_ended` (so every key present at commit
is revoked); the row stays; `409` once retired. It overwrites a stored
`revoked_reason`, which only makes a restore stricter, since retire is
terminal |
| `revoke-key` `{keyId}` | the owner, an administrator, a platform
administrator | `revoked: true`; a platform administrator's revoke of a
key with no reason records `administrator_suspended`; a stored reason is
never overwritten |
| `restore-key` `{keyId}` | the same | `revoked: false`,
`revoked_reason: null`; `organization_membership_ended` is never
restored (`403`, a platform administrator included);
`admin_password_reset` and `administrator_suspended` only by a platform
administrator |

- **Suspension is the ban** (D6): key admission already refuses every
key of a banned owner on its next call (`isUserBanInForce`), so suspend
writes nothing else and revokes nothing; resume lets the same keys work
again. There is no second suspension column and no second refusal path.
Because the ban state is shared with `/admin/ban-user`, this family
marks its own ban by its reason, never relabels a ban in force, and lets
an organization administrator lift only its own; a platform
administrator's stop stays a platform administrator's, as the restore
guard keeps it for a key (seat review, should-fix 1).
- **The owner leaves** (`service-account-owner-leaves.ts`): an
`afterDelete` on `sys_member`, plus an `afterUpdate` that moves
`organization_id`, the two seams `membership-ended-session.ts`'s census
measured. When the owner's membership of the account's organization
ends, every account that person owns in that organization is suspended
(`Accountable owner left the organization`). An account already
suspended keeps its own reason, and the owner leaving another
organization suspends nothing. It is best-effort and loud: a failed
suspension is logged with what it costs and never fails the removal.
Resume then refuses until an administrator names a new owner.
- **Key reasons.** These doors write as the system (an account's keys
are not the caller's own rows, and the `sys_api_key_self` RLS stays as
it is, per the seat's ruling), so they apply the restore guard's rules
1, 2 and 4 themselves, using its exported reason constants.
- **Ledgers.** Six rows in `auth-route-ledger.ts` (ObjectStack mount,
server-only) and the conformance list. The 3a create-key row's note is
corrected: past the ceiling it answers `400 VALIDATION_FAILED` at
`expires_at`, not `API_KEY_EXPIRY_TOO_LONG`.
- **Docs.** The lifecycle paragraphs in `content/docs/ai/agents.mdx` and
`content/docs/permissions/index.mdx`.
- **Module exports.** `service-account-doors.ts` now exports its door
helpers for the lifecycle module. These are not re-exported from the
package root.

## Tests

New, all green:
- plugin-auth `service-account-lifecycle.test.ts` (28, real ObjectQL +
SQLite with PR-1's row rules and the owner-leaves trigger). It covers:
- the who-may matrix for all six doors, each refusal beside its CONTROL;
  - the ban state written and cleared;
- resume refused for the owner, for a retired account, and for an
account whose owner left, until a new owner is named;
  - set-owner membership and person rules;
- retire's terminal state, with no mint after; a key written inside the
unit of work after the membership ends is revoked too;
- a platform administrator's ban: an organization administrator's resume
gets 403 and the account stays banned (CONTROL: a platform administrator
lifts it); the owner's suspend of a banned account leaves its reason
unchanged;
- one key's revoke and restore, the `administrator_suspended` stamp and
who lifts it, a stored reason kept, `organization_membership_ended`
never restored, a person's key unknown;
- the owner leaving (delete and org move), with CONTROLs: leaving
another organization, another member leaving, an existing stop kept;
  - 503 on an unreadable membership.
- dogfood `service-account-lifecycle.dogfood.test.ts` (5, showcase stack
over HTTP, each act judged at the data door with the account's real
keys):
  - an API-key caller gets 401 on the doors;
- the owner's suspend refuses both keys as a made-up key is refused; the
owner's resume gets 403; the administrator's resume admits them again;
- an administrator revokes one key: it answers `403 API_KEY_REVOKED` and
the other key works; the owner's restore gets 403 and the
administrator's restore works;
- the owner is removed through the vendor's
`/organization/remove-member`: the account is suspended, resume gets
409, then set-owner and resume admit it again;
- retire revokes both keys, after which create-key gets 409 and
restore-key gets 403.

Forced test edits:
- `auth-route-ledger.conformance.test.ts`: the six routes added to the
hand-written `own` list.
- Forced census edit: `content/docs/permissions/tenant-audit-census.mdx`
and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` count
the new engine writes (write call sites 286 → 292; the decidable,
undecidable and elevation-undecidable figures move with them).

## Acceptance notes

- With 3a, this completes ADR-0151 D1 to D4 and D6 for the card:
registration, key mint, suspend, resume, change owner, owner-leaves
auto-suspend, retire, and one key's revoke and restore.
- Open debt recorded on objectstack-ai#31739, not filed (the seat's p1-only rule): no
purpose column, because the name carries the purpose for now.
- No `sys_user` actions for the lifecycle doors in this PR (the seat's
3b scope is the doors); the Console reaches them by URL.
- `docs/adr/**` is untouched; the ADR-0151 status amendment is the
seat's.

## Verification

- plugin-auth suite on d0e1eef (rebased on main fb94249, after a
dependency build): 4560 passed, 10 skipped; `check:test-typecheck`
green.
- The review fix (9fd173c): lifecycle and ledger conformance tests 38
passed; both service-account dogfood files 13 passed; eslint,
`check:test-typecheck`, `check:engine-double-contract`, census,
auth-mount-ledger and route spelling green.
- On the stacked head before the restart (e300def + these commits):
- `dispatch-gates --ran`: 109 of 109 derived families run, all exit 0.
The first run's one red, `check:engine-double-contract`, was fixed: the
test's write wrappers now bind the real engine's own methods.
  - eslint on the changed files: clean.
- Dogfood under the lock: 8 files, 110 tests (both service-account
files, route-ledger parity, authz conformance and blind spot, admin
standing and refusal, banned-owner key).
- Census and auth-mount-ledger re-checked on this head: green.
- Ablations, each restored, with `git diff HEAD` empty after: 22 of 22
go red (17, plus 5 for the review fix: suspend keeps a ban in force;
resume of a foreign ban is platform-admin only; the suspension reason
carries the family mark; the owner-left reason is the family's; retire
reads the keys inside, after the delete).
  - suspend authority;
  - resume administrator-only; resume needs an owner in the org;
  - set-owner administrator-only; set-owner membership;
  - retire administrator-only; retire revokes every key;
  - revoke-key authority;
  - the platform-admin stamp; a stored reason kept;
  - restore rules 1 and 2;
  - the agent-kind 404;
- owner-leaves: only the account's org; an existing stop kept; the
delete seam; the re-point seam.
- Not pinned: a failure partway through retire rolling back. It runs
inside `engine.transaction` like 3a's registration, whose rollback is
pinned.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58

<!-- signed: domain:server#1 · os-steve ·
session_01ADHkmPK8RUPmLyYjQGCe58 -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…ollow-ups landed (objectstack-ai#32067)

<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi94hv8aAq8NNdK1XkR7nbh2C)_

Fixes objectstack-ai#32060

Clause-②: no
Metadata-protocol: none

## 维护者速读

- 改了什么:在 ADR-0150 加 6 条、ADR-0151 加 1 条带日期的修订说明,并在两份记录的头部各加一条 Amended
记录;只追加,原句和状态行一个字没改。
- 为什么改:G1 和它的跟进卡落地后,ADR 里有几处和代码不一致(例如地址字面量现在是 `os validate`/`os build`
拒、启动只告警;引擎在 B objectstack-ai#31696 之前每个裸名只挂一个流程),读 ADR 的人和 AI 会被带偏。
- 风险与回滚:纯文档,不发布任何包,不影响运行时;回滚就是 revert 本 PR。
- 先看:ADR-0150 的 D5 两条注(第 1 步下面和 "This is documented precedence" 下面),然后
D4 表下的那条临时说明,最后 ADR-0151 "What builds on this" 下的注。
- 两分钟验证:`pnpm check:adr-symbol-anchors`,预期全部锚点可解析(本 PR 新增 37 个,全部解析)。
- 需要你做的:批准本 PR(`docs/adr/**` 受管面,Tier H)。

## What changes

Append-only, dated notes in the form `> **Amendment note (2026-11-10,
objectstack-ai#32060 item N — PR #NNNN).**`, each directly under the sentence it
amends, plus one header `**Amended**` entry per record. No ruled
sentence, no `**Status**:` keyword and no README row moves.

| Item | Where (ADR-0150 unless named) | What the note records, read off
`main` 48f18b5 |
|:--|:--|:--|
| 1 | TL;DR 7, the address-literals bullet | The refused set is
`NAMESPACE_REFUSED_LITERALS` (`actions`, `connectors`, `global`,
`hooks`, `oauth`, `trigger`). `os validate` / `os build` refuse
(objectstack-ai#32016), `defineStack` warns once and loads (R1), the running install
door refuses a new package, and `os migrate stored-references` lists a
held one under `literalNamespaces`. |
| 2 | D5, under "This is documented precedence" | Seat ruling Q1 = A
(record 6754822117): after a namespace, a sub-resource literal in the
name position keeps its bare route (`META_ITEM_SUBRESOURCE_SEGMENTS`,
the automation twin `AUTOMATION_FLOW_SUBRESOURCE_SEGMENTS`). `bundle` is
not in the `/meta` set; it keeps its route on the REST route manager by
registration order. After a qualifier, a door's own literal is declined
(objectstack-ai#32001). |
| 3 | Under the D5 door table | The qualified hook's qualifier is never
`runs` (`INBOUND_HOOK_QUALIFIER_EXCLUSIONS`, objectstack-ai#32049). Install still
accepts a `runs` namespace; first-party count 0. Whether install should
refuse it is recorded as open, not decided. |
| 4 | Under the execution-plan table | Row K door by door: `defineStack`
/ `os validate` judge one artifact and not a dependency's namespace (a
dependency carries none); install judges both directions; publish and
the save door judge one direction; `MetadataManager.publishPackage()`
reads no namespace, the publish door judges first. |
| 5 | D5 step 1, the install-gate sub-bullet | R1: a package the boot
registers under such a namespace keeps booting, is warned once naming
`?package=ID`, and is read with `?package=`; an upgrade that moves to
one is refused. |
| 6 | Under the D4 table | Epic seat reading E3: between G1 and B objectstack-ai#31696
the engine arms one flow per bare name, so a two-holder flow's address
answers the same `409` as its bare path. It ends when B objectstack-ai#31696 lands. |
| 7 | ADR-0151, under "What builds on this" + header | O3 shipped as
objectstack-ai#31779, objectstack-ai#31867, objectstack-ai#31966 and objectstack-ai#31981; O4 as objectstack-ai#32036. One difference from D6:
an account key's revoke and restore are dedicated
`service-account/revoke-key` / `restore-key` doors, not the existing
self-key doors. The status line is left as it is. |

## Tests

No code changed, so no test was added or edited. Every statement in a
note cites a `path#symbol` anchor on `main` and, where one exists, the
test that pins it.

## Acceptance notes

- `docs/adr/README.md` is stale on `main` at 48f18b5 before this PR:
`node scripts/adr-index.mjs` adds the missing ADR-0153 row (from objectstack-ai#32014)
and moves the Proposed count 7 to 8. This PR changes no row (the
ADR-0150 and ADR-0151 rows regenerate byte-identical, since `Proposed`
stays the earliest status keyword), so the regeneration is left to the
ADR-0153 owner rather than ridden here.
- ADR-0150 line 3 already carried the objectstack-ai#31685 `**Amended**` entry on the
status line, after the `Proposed` keyword; the new entry follows it the
same way. ADR-0151 had none, so its entry is a header bullet, off the
status line.

## Verification

Gate readings are listed in the os-dev report on objectstack-ai#32060, taken on this
PR's final head.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmT2uyMEkw9cAQ8sLdd)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant