Repository navigation
feat(security)!: an AI service account is the agent principal of its own requests, under the agent ceiling - #31867
Conversation
…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>
Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58 Co-authored-by: Claude <noreply@anthropic.com>
…7 disposition 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>
📓 Docs Drift CheckThis PR changes 10 package(s): 64 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 95 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi Contract-review-claim: session_01ADHkmPF1skfu2kx89BEXZi · head 0c2ef42 · 2026-11-09T08:20Z Narrowing face ( |
|
domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi Contract reviewServed-tier: 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 ① Derived judgmentsEach accept-set or public-surface change the diff implies, named right or wrong against ADR-0151 D3 on
② Semver levelChangeset 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
④ Design judgmentThe narrowing against the published spec. ADR-0151 D3 on Human principals and the OAuth CONTROL. Every behavioural branch is gated on Bypass hunt. Cache: the grants cache ( The unconditional The refusal. Priority ordering, the post-write image on update, the Findings.
No must-fix. F1 earns a delta review of its fix on the next head. Implemented-by: 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>
… as optional Claude-Session: https://claude.ai/code/session_01ADHkmPK8RUPmLyYjQGCe58 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi Contract reviewServed-tier: Delta review of record 6735749732 (head 0c2ef42, PASS, F1 should-fix). Three commits, no merge, no rebase: a415adc (runtime test reads ① Derived judgments
② Semver levelUnchanged: ③ Boundary flags
④ Design judgmentWithholding the names in the resolver, rather than teaching each reader to read Implemented-by: VERDICT: PASS |
|
domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi Contract reviewServed-tier: Delta review of record 6736879466 (head 1166ca5, PASS). 946a4d2 is the seat's ① Derived judgmentsThe
② Semver levelUnchanged: ③ Boundary flagsNone new. The stored-row readers ( ④ Design judgmentNo 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: VERDICT: PASS |
|
domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi Contract reviewServed-tier: Delta review of record 6735749732 (head 0c2ef42, PASS, F1 should-fix). Three commits, no merge, no rebase: a415adc (runtime test reads ① Derived judgments
② Semver levelUnchanged: ③ Boundary flags
④ Design judgmentWithholding the names in the resolver, rather than teaching each reader to read Implemented-by: VERDICT: PASS |
|
domain:server#1 · os-steve · session_01ADHkmPF1skfu2kx89BEXZi Contract reviewServed-tier: Delta review of record 6736920285 (head 946a4d2, PASS). 1a14193 merges ① Derived judgmentsThe merged range is four commits: #31883 (app-shell), #31878 (service-analytics), #31859 (spec ② Semver levelUnchanged: ③ Boundary flagsNone new. ④ Design judgmentNo interaction. The thread's pre-push runs on this head: full build 81/81, core 3337, plugin-security 6072, and Implemented-by: VERDICT: PASS |
…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>
…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>
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>
…, 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>
…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>
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 gainsagent_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 levelpatch,!in its headline, ADR-0087not-required (no-migration-prescription).维护者速读
principal_kind = agent的sys_user)用自己的 API 密钥访问时,就是它自己这个 agent 主体:principalKind: 'agent'、没有onBehalfOf、拿不到everyone锚点权限集、没有系统权限、也永远不是平台或组织管理员。所有 agent 主体的权限集在求值时按「agent 上限」裁掉 View All、Modify All、转移/恢复/清除、系统权限和管理范围;给账号分配超上限的权限集(或绑定它的岗位)会被按名字拒绝。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 个用例全绿。What changes
resolveUserAuthzGrantsreads the user'ssys_userrow 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 whoseprincipal_kindisagentresolves asprincipalKind: 'agent', without theeveryoneanchor's sets, with no system permission, and with no platform or organization administrator standing, whatever its grants say. It never carriesadmin_full_accessor an organization administrator set by name either: standing is also read by name (the approvals override arms andadmin_rescueslate, the explainer's tenant-admin read), and the ceiling clears bits, not names.assembleExecutionContextcarries the kind onto the context, with noonBehalfOf. REST,/mcpand stdio keys all go through this one resolver, so the key admission needed no change of its own.resolvePermissionSetsForContextUnmemoized, the one choke point every evaluator reads, clamps every agent principal's sets (the account and the OAuth-delegated agent alike):viewAllRecords,modifyAllRecordsand the destructive class's keys (DESTRUCTIVE_CLASS_KEYS, derived fromDESTRUCTIVE_OPERATIONS) are cleared on every object entry,'*'included;systemPermissionsis emptied andadminScoperemoved. 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.sys_user_permission_setorsys_user_positionthat would leave an AI service account holding a set above the ceiling (directly, or through a position that binds one) is refused400 VALIDATION_FAILED,invalid_valueatpermission_setorposition, 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.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.principal_kindis 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:
resolve-authz-context.agent-account.test.ts(8),assemble-execution-context.test.ts(+3).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_userstand-downs),effective-access.test.ts(+2).security/resolve-execution-context.agent-account.test.ts(real resolver + real SecurityPlugin over/mcpand the dispatcher; clamp;everyoneand wildcard refused; human and OAuth CONTROLs),flow-run-agent-account-kind.test.ts,automation-connector-connection-door.test.ts(+1).run-agent-account-kind.test.ts(5).rest-api-key-agent-account.test.ts(2). mcp:__tests__/stdio-agent-account.test.ts(2).agent-account-not-an-administrator.integration.test.ts(6, real ApprovalService on ObjectQL + SQLite, envelopes from the real resolver): an account holdingorganization_adminis not on theadmin_rescueslate and an account holdingadmin_full_accessororganization_admincannot 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_userread is now unconditional, as approved):resolve-authz-context.batch-equivalence.golden.json: theseeded-permissions-and-emailcase 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 noPLATFORM_ADMIN; "…and costs no sys_user read of its own" now expects one read on both postures.resolve-user-grants-cache.test.tspin 5:sys_usercalls 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 expectsadmin_full_accessabsent; "an organization administrator set confers no organization standing…" now expectsorganization_adminabsent on the account and present on the person; plus a new pin over everyORGANIZATION_ADMIN_GRANTSname), andresolve-execution-context.agent-account.test.ts(the held sets containcrm_lead_superand notadmin_full_access).Acceptance notes
scripts/engine-double-contract.pinned.jsongains one row for the new runtime test'sfindOnedouble, written by the gate's own--write, as its message asks.Verification
Tests and typecheck
All under
scripts/pm/os-verify-lock.sh,npx vitest runper package, after merging main at 015ae70:grant-holder-membership-refusal.test.tsandtenant-wall-organization-removal.test.ts(the account refusal read an unreadable holder as the system, and threw where nosys_userobject is composed). Fixed in 0c2ef42; the re-run at the final head: 6056 passed / 45 skipped, 0 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-configon every touched.tsfile: exit 0.turbo run build --filter='./packages/**', lock): 81 of 81 tasks.Full dogfood battery (the `(narrowing)` arm)
pnpm --filter @objectstack/dogfood testat 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.everyoneskip, 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.sys_userguard removed: the composition pin red.Delta after the contract review (head 1166ca5)
Type Check · workspacewas red at 0c2ef42 on@objectstack/runtime#typecheck: a new test readexecutionContext.onBehalfOfwhere the test tsconfig types it possibly undefined. Fixed (a415adc); the new approvals file had the same class of error, fixed (1166ca5).check:test-typecheckOK on all eleven touched packages.admin_full_accessand everyORGANIZATION_ADMIN_GRANTSname from an account'spermissions. 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 HEADempty.tsc --noEmitexit 0 for all three. eslint exit 0.packages/(non-test): on an envelope'spermissions,plugin-approvalsisOverrideActorandholdsTenantAdminRung(closed here) andplugin-securityexplain-engine.ts#derivePostureon a posture-less context (closed here). On stored grant ROWS, which the resolver change does not reach:plugin-authlast-admin-guard.ts(counts a Default Organizationadmin_full_accessrow as an administrator) andplugin-securityupgrade-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 carriesnot-required (no-migration-prescription)and the BREAKING banner.check:engine-double-contract: the clamp test's undrivenfindOnedouble dropped; the runtime test's pinnedfindOnerecorded by the gate's own--write.check:objectql-double-limit: the REST double applieslimitafter the filter.check:where-matcher: the stdio double refuses a combinator or operator.check:test-source-alias: module-topimport '@objectstack/objectql'(REST) andimport '@objectstack/metadata'(connector door test).check:system-context-census: row 23g added and the register regenerated.check:type-check-debthit 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