Skip to content

feat(plugin-security): a boot report lists, per organization, every catalog reference whose name resolves nowhere (ADR-0131 D3/D4, C3 stage 3) - #22671

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-15204-s3-boot-report
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-15204-s3-boot-report

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15204
Clause-②: no

Stage 3 of the C3 cutover stage plan (6094501866 on #15204): the S9 dangling-name boot report, under the claim amendment 6095710107. #15204 stays open for its other stages; this PR carries no closing keyword. The S9 content is the list #15196's releases carried to it (6080018188, 6084206172, 6093100744), with the rulings Q3 A (6050490870) and Q (a) (6094045326).

Landing order: after stage 1. The report's "confers nothing" holds for a named reference only once the authorization resolver reads positions, sets and set bodies from the catalog (stage 1). One assertion is pending stage 1 (below).

What changes

  • catalog-reference-report.ts (new, @objectstack/plugin-security). It reads three tables: sys_user_position, sys_user_permission_set and sys_position. Every name is resolved through createSecurityCatalogReader (@objectstack/core, already on main). collectCatalogReferences returns a structured result; reportCatalogReferences also logs it, one line per kind of finding. The report writes nothing.
  • Wiring in security-plugin.ts: one kernel:bootstrapped hook, registered after the grant-name backfill and the row-only position backfill. Handlers run in registration order, so a name either backfill resolves during the boot is not reported. Nothing runs without a hook-capable context: a report at start() would judge a catalog that is still filling.
  • grant-permission-set-name-backfill.ts: two warning sentences and two module-doc lines that said such grants "still resolve by id today". That stopped being true when the resolver began reading grants by name (C2 stage S5a). They now say these grants confer nothing until a later boot names them.
  • One dogfood HTTP pin (packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts) for S10's Setup answer (E2).
  • .changeset/15204-s3-boot-report.md (@objectstack/plugin-security, minor). It names what the report lists and where the operator reads it.

Which S9 items this covers

# S9 item Here Pinned by
1 Assignment rows whose name resolves nowhere, per organization (sys_user_position.position, sys_user_permission_set.permission_set) positionAssignments, permissionSetGrants per organization catalog-reference-report.test.ts: "an assignment and a grant whose names resolve nowhere are listed under their own organization, and nowhere else" (three buckets: organization-less, two organizations)
2 A grant on a catalog-unresolved, row-only set: unnamed, confers nothing unnamedGrants per organization, with the grant's permission_set_id; every unnamed grant is listed, whatever the reason the backfill left it unnamed "a grant on a row-only set stays unnamed after the name backfill, confers nothing, and is listed". It asserts both halves on this tree: the resolver grants neither the set's name nor its system permission.
3 Walled, organization-authored positions with no catalog home, by name and organization (Q3 A) positionsWithoutDefinition per organization under a walled posture. The organization's seeded built-in copies resolve and are not listed. "under a walled posture, an organization-authored position has no catalog home..."
3a Q3 A's promotion path A definition of that name saved through the metadata door (saveMetaItem, type position) clears the position from the next report "under a walled posture, a definition saved through the metadata door gives the position its catalog home, and it leaves the report"
4 Same-name position rows stored under single before S10 (Q (a)) conflictingPositionNames, under single only: every name more than one sys_position row carries, with each row's organization and whether the catalog resolves the name. Reported as conflicting, never merged. "under single, a position name more than one row carries is conflicting...": two organizations' agreeing rows, and a package's seeded row beside an organization's pre-S10 row
5 The S7 backfill's refused-name class Each entry of positionsWithoutDefinition carries refusedName. The S7 backfill records its verdict and stops reporting the class; this report lists it at every boot. "under single, the refused-name class stays listed at every boot, after the row-only backfill has recorded its verdict"
6 HTTP pin of S10's Setup answer (E2) Showcase, single: POST /api/v1/data/sys_position under manager (package-held) and everyone (built-in anchor) answers 403 NOT_OVERRIDABLE. The rows stay as they were and no environment definition is written. A rename into manager answers the same. the dogfood file above
7 The S4b warning sentence gains "confers nothing until then" grant-permission-set-name-backfill.ts not pinned: the wording is not parsed by any consumer

Also pinned:

  • A catalog that cannot be read, or a scan that cannot be read, stops the report. It reports no finding and logs the stop once at warn.
  • A composition without the three objects reads nothing.
  • The wiring pin: the report runs after both backfills, a name either backfill resolved is not reported, and the report's handler issues zero insert / update / delete calls.

The fail-closed half: pending stage 1

The stage-plan test reads "a removed declaration fails closed and is reported; re-adding it clears both". On this tree the report half holds and is active ("the grant and the assignment are listed while the declarations are gone, and drop out once they are back"). The resolver half does not hold yet, so it is written but skipped:

  • The test to enable: packages/plugins/plugin-security/src/catalog-reference-report.test.ts, it.skip('PENDING STAGE 1: the removed declaration fails closed at the resolver, and re-adding it restores the grant' → it(.
  • Measured on this tree (commit 2d5ca52d, un-skipped through scripts/ablation-replace.mjs, restore proven blob == HEAD): it fails at the fails closed assertion. The holder still gets { auditor: true, bit: true } after the declarations are removed, because the set row and the junction row still grant.
  • Measured on stage 1's head (claude/issue-15204-s1-position-permission-sets at 8cbedf96, with this branch's commits cherry-picked into a throwaway worktree and the test un-skipped): Tests 13 passed (13). The pending case passes there, and the other twelve pass beside stage 1's resolver. The worktree was then removed.

Measured on the real composition

  • Showcase boot (the dogfood harness, single). The boot log carries, in order: the grant-name backfill's record line, the position backfill's record line, then the report's line, "every position assignment and permission-set grant names an item the security catalog resolves at this boot, and no position row is without a definition". So there is no false positive on a fresh showcase.
  • Ablation of the E2 pin. heldPositionNameRefusal was made to return null (scripts/ablation-replace.mjs, then pnpm --filter @objectstack/plugin-security build). scripts/ablation-dist-preflight.mjs found the marker in dist/index.js and dist/index.mjs. The pin then went 3 red / 1 green: the creates of manager and everyone answered 201 and landed an organization row beside the seeded one, and the rename answered 409. The precondition case stayed green. Restored, rebuilt, and --absent confirmed: the marker is absent from all 6 built files and the tree is clean.

Tests and gates

What Head Result
pnpm --filter @objectstack/plugin-security test (full package) 613a5b56 (pre-merge) 196 files: 1 failed, 4112 passed, 46 skipped. The one failure was the handler-count pin below. After the fix (54864c2a), the three touched files re-ran 71 passed, 1 skipped.
pnpm --filter @objectstack/plugin-security typecheck (incl. the test layer) 115660d7 exit 0
catalog-reference-report.test.ts 115660d7 12 passed, 1 skipped
dogfood position-held-name-setup-create.dogfood.test.ts aafa8cd9 4 passed
node scripts/pm/dispatch-gates.mjs --commands (70 families) + check:startup-registry-verdict + check:durability-log-level ad45b764 (origin/main 5fb17466 merged) all exit 0; --ran reconciliation: 70 derived, 70 run, 0 NOT-MEASURED (derived from recorded exit codes)
post-merge re-run of the package tests, typecheck and the dogfood pin ad45b764 (origin/main 5fb17466 merged) NOT MEASURED (three lock acquisitions timed out, 27 minutes queued, behind another session's objectui test run). Declared to CI, which runs these on the merged tree. The merge brought six commits, none touching this diff's files.
  • One existing pin changed. grant-permission-set-name-backfill.test.ts pinned the kernel:bootstrapped handler count at 2. It is now 3, and its comment names the report.
  • One gate fix during the run. check:query-options-erasure counted a new test-surface erasure (236 → 237): a pass-through find options argument cast to any. It is now typed EngineQueryOptions, and the ratchet holds at 236.
  • Declared to CI: the dogfood suite beyond this one file, and every importer of @objectstack/plugin-security. Its published surface is byte-unchanged (nothing new is exported), but every boot that composes SecurityPlugin now runs the report.

Acceptance notes

  • Sharing rules are outside this report's "fails closed". A sharing rule with a position recipient expands its holders from sys_user_position rows (plugin-sharing/src/sharing-rule-service.ts, recipient_type === 'position'), independently of the catalog. So a removed position declaration keeps its sharing-rule grants until that reader moves. That move is planned in stage 2 (the services readers). The report lists the assignments either way.
  • Not measured: the HTTP authorization of the promotion path. Q3 A's promotion was measured at the metadata door's own seam (saveMetaItem, actor: 'system') under isolated. Whether a platform administrator's PUT /api/v1/meta/position/NAME is accepted under a wall was not measured here.
  • The two position categories read sys_position. Stage 8 retires that object. After that they read nothing and name the object as absent, so their population is C7's migration report from then on.
  • Every assignment row is read at every boot (paged, 500 per page). It is deliberately not a NOT IN query on the resolved names: under a case-insensitive collation (MySQL's default) such a query would hide a case-variant dangling name.
  • metadataDoorAcceptsPositionName is imported from position-write-through.ts, which stage 7 retires. The S7 backfill imports it too. It is S7's predicate, read and never re-derived.
  • Untouched (batch Release version 0.4.0 #310 item 4): the seven seeders, permission-set-projection.ts and per-organization-catalog.ts. The report reads organization ids with a local three-line helper rather than adding a consumer to per-organization-catalog.ts.

Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 55 documentable anchor(s).

40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json bfc15d275ba3fed8406cd5836b826e617150fae0.

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

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

Coarse fallback — 16 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 bfc15d275ba3fed8406cd5836b826e617150fae0 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 bfc15d275ba3fed8406cd5836b826e617150fae0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Brings in stage 1 (bfc15d2): the resolver reads positions' permissionSets
from the security catalog and deactivation from sys_metadata_activation.

Claude-Session: https://claude.ai/code/session_01VUp6VuRm8SBjud6b5CwjSV
Co-authored-by: Claude <noreply@anthropic.com>
…talog reference report

Stage 1 moved the authorization resolver onto the security catalog, so the
case that was parked behind it runs now. Its setup comment no longer says the
junction row is a binding the resolver reads.

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

Copy link
Copy Markdown
Contributor Author

os-dev-report addendum (stage 3, post-stage-1 patch round) · cloud dev session_01VUp6VuRm8SBjud6b5CwjSV · 2026-10-11T02:21Z

Dispatched by epic PM session_01Rerax7QTjKMPCUZxQUtPFR under claim amendment 6095710107; the task list is the landing condition of the seat's ACCEPT 6096484607. The PR body is left as written (one-write rule); this comment supersedes its "pending stage 1" section and its NOT MEASURED row.

Head: cafbe826a (pushed). Two commits on top of ad45b7645b:

  • 12c7ea4b merges origin/main at bfc15d275ba3 (stage 1's squash). A plain merge: no conflict, and none of this PR's 7 files is a merge=os-regen path, so os-regen-merge.sh was not needed. Stage 2a's hunk in security-plugin.ts was not touched.
  • cafbe826 enables the pending case and corrects one stale setup comment (below).

Size against the new merge base bfc15d275: 1,289 changed lines (+1,274 / -15), 7 files, read from dispatch-gates.mjs. Under 3,000.

The enabled case. In catalog-reference-report.test.ts, it.skip('PENDING STAGE 1: the removed declaration fails closed at the resolver, and re-adding it restores the grant', ...) is now it('the removed declaration fails closed at the resolver, and re-adding it restores the grant', ...). The PENDING comment is replaced by one saying why it holds. Result on the merged head: Test Files 1 passed (1) · Tests 13 passed (13), exit 0. It passed as written; no assertion changed.

Premises re-verified on the merged head

Premise Verdict
Item 2, "an unnamed grant confers nothing" true. The resolver reads a grant's set by name and the set body from the catalog. The item-2 case passes and asserts that the resolver grants neither the set's name nor its bit.
Item 1, "a grant naming a set the catalog does not resolve confers nothing" true, now also for a set whose row stays. The enabled case shows this.
Category 4, "assignments to a position with no definition distribute no permission set" true. The resolver reads the position and its permissionSets from the catalog, so a sys_position row adds nothing.
Does any category read a table or an active flag that stage 1 stopped reading? No. The report reads only names from sys_user_position, sys_user_permission_set and sys_position, and resolves each through createSecurityCatalogReader. It reads no active column and no junction row. sys_position is the read for categories 4 and 5. Those categories are about rows, not grants, so they hold.
POSITION_OBJECT and metadataDoorAcceptsPositionName exported from position-write-through.ts with the same meaning true. :108 and :222. Stage 1 changed only saveDefinition there: it now carries permissionSets through a row edit.
The report's catalog read matches the resolver's true. The hook builds createSecurityCatalogReader({ registry, metadata }) over the same engine registry and metadata service that stage 1 passes to bindSecurityCatalogReader. Both backfills beside it build theirs the same way.
Changeset sentences true; no edit.
Test names and comments fixed, one. seedHolder's comment said the junction row is "the binding ... today". It now says the resolver reads only the definition, and that the junction row is left to show a leftover row keeps nothing alive.
PR body, "Landing order: after stage 1" and "The fail-closed half: pending stage 1" stale; superseded here. Stage 1 has landed and the case is enabled. Under the one-write rule the dev does not edit the body.
Acceptance note: sharing rules expand position holders from sys_user_position still true on main: sharing-rule-service.ts:1447, and readPositionActive at :1606. Stage 2b moves it.

Measured at cafbe826a. Exit codes were captured before any pipe; heavy runs went through os-verify-lock.

  • pnpm --filter @objectstack/plugin-security test: Test Files 199 passed (199) · Tests 4139 passed | 45 skipped (4184), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-security typecheck, which covers tsc, tsconfig.scripts.json and check:test-typecheck: exit 0 (test layer 0 errors).
  • Dogfood position-held-name-setup-create.dogfood.test.ts, after building the @objectstack/dogfood^... closure: Tests 4 passed (4), exit 0.
  • Derived gates: dispatch-gates.mjs --commands derived 70 families against bfc15d275, and all 70 ran. --ran printed 70 run, 0 NOT-MEASURED (a DERIVED zero). check:startup-registry-verdict and check:durability-log-level also ran, each exit 0.
    • In the first sweep, four commands refused on prerequisites and found nothing:
      • check-plugin-teardown-shape --self-test exited 3: its positive-control commit is outside the shallow clone.
      • check:dual-build-cjs-loads and check:i18n exited 3: unbuilt packages.
      • check:dts-closure exited 1: platform-objects declarations were missing while a parallel build was still writing them.
    • After fetching that commit and building the workspace (turbo, 72 of 72 tasks), all four re-ran at exit 0, and those are the codes recorded.
  • Not run locally: repo-wide pnpm lint, which belongs to CI.

Open (to the PM, not built here): a junction-only binding is invisible to this report. A position whose definition resolves, but whose sets are bound only through sys_position_permission_set rows, has distributed nothing since stage 1. Its names resolve, so the report reads clean. Converting those rows is C7b's job (#15211, convertPositionBindingRows), and release-cut condition (b) already depends on it. Per the dispatch, no category was added.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 02:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 149294c Oct 11, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15204-s3-boot-report branch October 11, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants