Skip to content

fix(cli): the ownership ceremony names the uncomposed security plugin, never removal, for a shipped set or built-in position - #23540

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23359-uncomposed-security-remedy
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23359-uncomposed-security-remedy

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23359
Clause-②: no

What was wrong

os migrate organization-ownership --apply composes the security plugin only where os serve would: behind the auth gate, which needs OS_AUTH_SECRET or a development boot. That plugin declares the platform's shipped permission sets and the built-in positions. Run without a secret, the ceremony's catalog resolves none of them, and the ceremony blocked with a remedy that pointed at the rows, not at the cause.

Measured on main before this change: 4 remedy sentences in 2 steps, all from this one cause.

step rows remedy printed before
rewrite a grant that names admin_full_access / organization_admin_no_bypass (the card's setup) "Declare or author the set, or remove the grants, then re-run."
rewrite a legacy grant (a 17.x database: permission_set empty) whose set row is admin_full_access "The rewrite could not derive their name (see its notes); name or remove each, then re-run."
authored-conversion the seeded everyone binding (the repo:cloud#1 instance, 6167973978) "Declare the position (or remove the bindings), then re-run."
rewrite gate, under a wall the same everyone binding "The authored-conversion step carries them; resolve its blocker, then re-run." (that step only reports under a wall, so it has no blocker to resolve)

The dispatch's mechanism hypothesis (a) named the first and third rows. I measured the second and fourth in the same function as the first (verifyReferences, the rewrite step's gate). Both have the same cause, and the second prints removal advice for a shipped set, which triage's ruling forbids ("Never print "remove the grants" for a shipped set."). So this PR covers all four. Each one is pinned, and each is listed under Acceptance notes.

What changed

  • The composition fact comes from the boot and is never inferred. ApplyOptions.securityPlugin carries bootSchemaStack's own composition.securityPlugin, the same field os migrate security-catalog-overlays prints. The command passes it at the head of the applyOrganizationOwnership options literal (one line).
  • Names come from their owners. uncomposedSecurityRemedy (in organization-ownership-apply.ts) answers only when the boot reported the plugin composed: false and the name is one the plugin declares:
    • shipped sets: securityDefaultPermissionSets (@objectstack/plugin-security), the list its manifest registers;
    • built-in positions: BUILTIN_IDENTITY_NAMES and AUDIENCE_ANCHOR_POSITIONS (@objectstack/spec), the two lists the plugin's securityBuiltinPositions is built from (that list is not exported).
  • One spelling. The new remedy is describeSecurityPluginComposition(status), followed by the one instruction that holds for every gate reason: "NAME is one of the permission sets it ships: keep the grants, and re-run where the security plugin is composed." For a position it says "built-in positions it declares" and "keep the bindings". No third spelling of the composition, and no edit to schema-migration-plugins.ts.
  • The legacy-grant case uses the rewrite's own decision. unnamedForComposition reruns planGrantRewrite over the unnamed grants. Only its unresolved reason, for a shipped name, moves to the composition blocker ("their set row is NAME, which the security catalog does not resolve."). A grant unnamed for any other reason (no id, a missing row, another organization's row) keeps the general blocker.
  • Every step still refuses. Nothing is resolved, skipped, guessed or deleted. A name the plugin does not declare keeps its remedy, and so does every name when the boot composed the plugin or did not say.

Measured once on a real boot with no secret (bootSchemaStack with the command's four composition flags, NODE_ENV=production, no OS_AUTH_SECRET; a throwaway file, deleted after the run and not committed): composition.securityPlugin was {"composed":false,"reason":"no-secret"}, and none of admin_full_access, organization_admin_no_bypass or everyone resolved. The card's two named grants then blocked with "1 grant(s) name the permission set admin_full_access, which the security catalog does not resolve (grant_admin). Did not compose the security plugin: os serve composes none here (no OS_AUTH_SECRET, and not a development boot: no --dev, and NODE_ENV is not development), so its shipped permission sets are not package-held names on this deployment's boot. admin_full_access is one of the permission sets it ships: keep the grants, and re-run where the security plugin is composed." organization_admin_no_bypass got the same sentence. A 17.x binding to everyone got the position form, in authored-conversion (single) and in the rewrite gate (isolated). A binding to the undeclared zz_undeclared kept today's remedy in both.

Pins

  • organization-ownership-apply.test.ts, describe "a name only the uncomposed security plugin declares". Each case reads its unknown-name control before its shipped name:
    • rewrite: grants naming admin_full_access and organization_admin_no_bypass get the composition and no "remove". A grant naming zz_unknown_set keeps "or remove the grants". Mirror deletion is not reached and nothing is deleted.
    • rewrite: a legacy grant whose set row is admin_full_access gets the composition and no "remove". A legacy grant on an unknown set row keeps "name or remove each".
    • authored-conversion: the everyone binding gets the composition and no "remove". zz_undeclared keeps "or remove the bindings".
    • the rewrite gate under a wall: the same binding pair.
    • controls (composed: true, and absent): both steps keep every remedy and print no composition sentence.
  • organization-ownership-apply.served-catalog.integration.test.ts: the composed control on the real served boot. composition.securityPlugin is { composed: true }, and the rewrite verifies grants naming admin_full_access and organization_admin_no_bypass, 2 of 2. Its ceremony() helper now passes the boot's own securityPlugin, as the command does.
  • organization-ownership.fate-supplement.integration.test.ts: the command hands the boot's composition.securityPlugin to the apply step. The boot double gained the composition field the command now reads.

Ablations (node scripts/ablation-replace.mjs, WRAP mode; restore proven as blob == HEAD with an empty git diff HEAD):

  • A. Drop the composition branch (if (!status || status.composed) return undefined; becomes return undefined;). The 4 uncomposed pins go red at their shipped-name assertion (lines 2054, 2079, 2096, 2113), after their unknown-name assertions held. The 4 control cases stay green: Tests 4 failed | 4 passed. Restored to d3dd0eda148f.
  • B. Drop the command's wiring line. The wiring pin goes red, and the file's other 8 stay green. Restored to a4ce0f93c21f.

Verification

On the final head 7d73667169. It merges origin/main fba37122b0, which touches only packages/lint; that package was rebuilt.

  • pnpm --filter @objectstack/cli typecheck: exit 0, including check:test-typecheck.
  • Integration project, the three touched suites (apply, served-catalog, fate-supplement): Tests 101 passed.
  • ESLint on the five changed TypeScript files, with pnpm lint's own --no-inline-config: exit 0. This is a narrowed pnpm lint; why that is sound is under Acceptance notes.
  • Gate families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands.
    • 63 exited 0.
    • pnpm check:dual-build-cjs-loads and pnpm check:i18n-coverage exited 3 (PREREQUISITE NOT MET), because this worktree has no full pnpm build. NOT MEASURED locally: CI's Lint & Repo Gates runs both.
    • --ran with each exit code recorded: "65 derived famil(ies) accounted for — 63 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)", 0 UNRUN.

On 563552a332. The source is the same; the later commits only reorder assertions in one test file and add the changeset and the merge.

  • @objectstack/cli unit project, the whole of it: 286 passed files, 4286 passed | 29 skipped tests. Two suites first failed for a missing prerequisite ("packages/cli is not built"), so they were not measured on that run. After pnpm --filter @objectstack/cli build they passed (36 tests, with organization-ownership.apply-flags.test.ts).
  • Integration project, every file that reaches the changed modules (apply, served-catalog, not-declared, one-shot-family, fate-supplement, column-drops): Test Files 6 passed, Tests 195 passed.

Acceptance notes

  • Scope beyond the two named sites. The two added sentences are in the rewrite step's own gate (verifyReferences), where no other claim holds code. Without them, a 17.x database (legacy grants) or a walled posture would keep the card's defect.

  • The control route. The composed control runs on OS_AUTH_SECRET (the served-catalog suite's existing boot), not on NODE_ENV=development. The auth gate answers { composed: true } on either route, and no second boot was added.

  • No committed no-secret real-boot pin. The served-catalog suite measured Duration 28.28s, of which import 23.29s, so a second file would pay that load again. The chain is pinned in three links: the boot's no-secret answer (security-catalog-overlays.integration.test.ts, existing), the command's wiring (new), and the remedy given the answer (new). The real no-secret boot was measured once (above).

  • describeSecurityPluginComposition's no-secret reason names --dev. --dev is os serve's flag; this command has none. It is left unchanged, as the dispatch requires: schema-migration-plugins.ts is reused, not edited.

  • The rewrite's notes are unchanged. The note "unnamed grant(s) point at a set row whose name the security catalog does not resolve" still lists a shipped-set grant without the cause. The blocker beside it now names the cause.

  • Why narrowing pnpm lint to the changed files is sound. Three facts:

    1. the changed TypeScript files are exactly those five (dispatch-gates change set, merge base fba37122b);
    2. eslint.config.mjs enables no type-aware or cross-file rule ("never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file"), so no other file's verdict can move;
    3. the run used pnpm lint's own --no-inline-config and exited 0.

    CI runs the full pnpm lint.

  • Out of scope, found while measuring, reported to the seat and not changed here. Under a wall, a 17.x binding to a position no catalog declares blocks the rewrite gate with "The authored-conversion step carries them; resolve its blocker". With the plugin composed or not, that step only reports under a wall, so it has no blocker to resolve. The zz_undeclared cases above reproduce it.


Generated by Claude Code

… for a shipped set or built-in position (WIP)

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…ership ceremony (WIP)

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…he command's wiring (WIP)

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 11 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/data-modeling/drivers.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/deployment/cli.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/kernel/runtime-services/audit-service.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/permissions/authentication.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/permissions/capabilities.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/delegated-administration.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/permissions/permission-sets.mdx (via user_id (literal, a string literal in unnamedForComposition), os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/positions.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/permissions/record-view-auditing.mdx (via user_id (literal, a string literal in unnamedForComposition))
  • content/docs/permissions/system-context.mdx (via permission_set_id (literal, a string literal in unnamedForComposition), user_id (literal, a string literal in unnamedForComposition))
  • content/docs/protocol/kernel/config-resolution.mdx (via user_id (literal, a string literal in unnamedForComposition))

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

  • content/docs/releases/v17/17-5.mdx (via user_id (literal, a string literal in unnamedForComposition))

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

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

Coarse fallback — 28 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 fba37122b0f6646a3d8dd0b5384319e8bed3ae12 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 fba37122b0f6646a3d8dd0b5384319e8bed3ae12 → 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

席位核对

✅ ACCEPT: PR #23540 at 7d73667169 (#23359). It lands on an all-green head; no contract review is owed · 2026-10-14T20:49Z

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6176811990, with the surface supplement on #23359 posted beside this record. Report: the os-dev-report on #23359. Thread-read: that report.

Checklist, read against GitHub and origin/main, not against the report:

  • PR shape: draft, base main, title fix(cli):, assignee sunhaolin. Line 1 is Fixes #23359, the body's only closing keyword; line 2 is Clause-②: no.
  • Scope: 6 files, +277 / −5:
    • organization-ownership-apply.ts;
    • one line in the command;
    • three test files;
    • one changeset.
    • check-governed-merges --pr 23540: NOT governed. Merge-tree is clean against origin/main 51b9990f47. ApplyOptions is not exported from @objectstack/cli's entry, so its new optional field is internal.
  • The fix, read in the diff:
    • The composition fact is the boot's own answer: stack.composition.securityPlugin, passed as ApplyOptions.securityPlugin. It is never inferred from a missing name.
    • One helper, uncomposedSecurityRemedy, answers only when the plugin is known uncomposed AND the name is one its owner declares:
      • shipped sets come from @objectstack/plugin-security's securityDefaultPermissionSets;
      • built-in positions come from @objectstack/spec's BUILTIN_IDENTITY_NAMES + AUDIENCE_ANCHOR_POSITIONS.
      • It then prints describeSecurityPluginComposition's own sentence plus "keep the grants / keep the bindings, and re-run where the security plugin is composed". No third spelling of the composition sentence, and no hard-coded names.
    • Every other case keeps today's remedy: an undeclared name, a composed run, or an unstated one.
    • Every step still refuses.
  • Pins and reverse verification:
    • The 3 touched suites pass at the head (101 tests). The 6 integration files that reach the changed modules pass (195). @objectstack/cli's typecheck passes.
    • Before the fix, the two extra sites went red against the two-site version.
    • Ablation A (the composition branch disabled) turns the 4 uncomposed pins red at their shipped-name assertion, after their unknown-name assertions held; the 4 controls stay green.
    • Ablation B (the command's wiring line dropped) turns the wiring pin red.
    • Both restores are proven against HEAD.
    • One throwaway real-boot measurement (NODE_ENV=production, no OS_AUTH_SECRET) read composition.securityPlugin = { composed: false, reason: no-secret }, and the card's two named grants and the 17.x everyone binding all printed the new remedy.
  • Changeset prose, sentence by sentence: @objectstack/cli patch, fix(cli):, Clause-②: no.
    • Four FROM sentences, each the exact old remedy, and one TO for sets and one for positions; they match the diff.
    • The "Unchanged" list matches the guards: still refuses, undeclared names and composed runs keep their remedy, and other unnamed grants keep the general blocker.
  • Gates:
    • Local, at 7d73667169: the dev ran 65 derived gates: 63 run and 2 NOT MEASURED, both exit 3 on a prerequisite (no full build), which CI runs. Lint was taken as the proven narrowing.
    • Gate evidence: the check-runs pinned to 7d73667169, read at this stamp. There are 37 runs: 20 success, 6 skipped, 11 still running, and none failed.
      • Every skip is on the expected-skip roster: Build Docs, Dogfood Regression Gate (${{ matrix.shard }}/3), Temporal Conformance, Console Pin Gate, Dogfood Verify CLI, and Packed-tarball smoke.
      • Landing reads them again.
  • Contract review: none owed. The diff touches none of the three faces: operator text in packages/cli, Clause-②: no, no schema, no governed path, no published export. CI plus this seat's read.

Accepted deviations:

Out-of-scope findings, one line each:

Landing: pr_ready + automerge_enable through the relay once every check on the current head is success or a roster skip.


Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants