Skip to content

fix(plugin-security): the position refusal reads no retired catalog table and names the catalog type it judged against; the unimported per-organization catalog module is deleted - #24365

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-15196-retired-catalog-residue
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-15196-retired-catalog-residue

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #15196
Part of #15204
Clause-②: no

What this does

This is the last item of #15196 and stage 6c of #15204, in one PR. Both parts remove residue the retired security catalog left behind, and both do it by deletion. That follows the cutover batch rule (6094179271): only delete, never fix the retired modules.

  1. position-catalog-refusal.ts. The id-to-name hint (idSpellingHints) read sys_position, which is not registered on main. The hint is deleted, together with its record-id read context and the hint form of the message. The refusal envelope's constraint.target also named that retired table. It now names position, the security catalog type the value is judged against.
  2. position-catalog-refusal.test.ts. The suite no longer boots sys_position, sys_permission_set and sys_position_permission_set from the legacy testkit. It now boots the way production does.
  3. per-organization-catalog.ts and its test are deleted. The module had 28 lines, one export and no importers.
  4. scripts/check-logger-receiver-detach.mjs. The gate's must-not-fire control had a provenance comment naming the deleted file. It now names a live file that uses the same spelling.
  5. A patch changeset: .changeset/15196-retired-catalog-residue.md.

Landing point: the file surface of claims 6251403239 and 6251408540. Nothing outside it changed.

Measurements

These were taken on origin/main dd72a047, in this branch's worktree.

M1: the hint never fires in production. Confirmed.

  • No non-test code declares or registers sys_position. git grep "name: 'sys_position'" finds 0 hits outside tests (control: name: 'sys_user_position' finds its object file). git grep '\bSysPosition\b' finds 0 hits outside tests and testkits.
  • I ran a real ObjectQL engine over SqlDriver with no sys_position registered. find('sys_position', …) throws OBJECT_NOT_FOUND (404). The control, find on a registered object, answers []. The hint's catch swallowed that throw, so it never returned a hint.
  • Behavioural proof, commit 35a9404c: with the suite moved off the legacy testkit and the source still unchanged, all 25 cases pass. The two record-id cases already read the generic remedy, so the hint was absent before any source change.
  • The verdict reads only the security catalog: securityCatalogReaderOf reads the registry, then the metadata service, and never a row. Apart from the sys_user_position pre-image, the hint was the module's only row read.

M2: readers of this refusal's constraint.target. None outside its own suite.

  • objectstack: there is no non-test reader of constraint.target or constraint?.target. The only two hits are this suite's own pins. The refusal builds its message itself rather than through renderValidationMessage, whose six callers are other refusals. So no {{target}} template ever renders this value.
  • objectui (/home/user/objectui at 43efd07): git grep over *.ts and *.tsx for constraint.target, constraint?.target and .constraint readers finds one hit. It is a TypeScript AST typeParameter.constraint in a guard test, unrelated to field errors. The field-error consumers read code and message only: plugin-grid/src/ImportWizard.tsx switches on code, and AssignedUsersSection shows message. No renderer and no i18n key reads 'sys_position' from a refusal.
  • Choice: target: 'position', with targetField: 'name' unchanged. position is the security catalog type the refusal resolves the value against (POSITION_CATALOG_TYPE). Reasons:
    • it names what the value was actually judged against;
    • the sibling refusal on sys_user_permission_set.permission_set (grant-permission-set-name.ts) already sends target: 'permission', pinned in its suite, so both catalog-reference refusals now use the same form.
      The engine's lookup refusal puts an object name in target because a lookup points at an object. This column points at a catalog type.
  • Published surface: neither narrowed nor widened.
    • No schema changes: FieldErrorSchema.constraint stays an open record.
    • No accept set changes: the same values are accepted and refused as before.
    • One runtime value changes: fields[].constraint.target goes from sys_position to position. I found no reader of it in either repo, and the changeset states the new value.
    • POSITION_CATALOG_OBJECT and idSpellingHints were exported from an internal module only. Neither was in the package's index.ts.

M3: per-organization-catalog.ts has no importer. Confirmed.

  • rowMatchesDeclaration has one hit: its own definition.
  • per-organization-catalog appears in no import specifier. The remaining hits are prose: ADR-0131, the plugin-sharing comment covered under M4, and the gate's control comment.
  • Positive control for the grep spelling: it finds the import … namesWithoutCatalogRow line in the refusal suite.

M4: stale pointers.

Test deletion: case 6 of per-organization-catalog.test.ts

Case 6 never imported the module it was filed under. It pinned resolveUserAuthzGrants from @objectstack/core, reached through core's dist/, against sys_position and junction rows booted from the legacy testkit. Core's own resolver suites pin each of its assertions, importing the resolver from its source:

Case 6 assertion Duplicate (same function, in its own package, imported from source)
A member of the other organization does not collect the binding organization's junction set packages/core/src/security/resolve-authz-context.test.ts › §6a › "a member removed from alpha: the definition's set and nothing alpha bound — with no tenant and in beta"
The binding organization's own member does not collect it either Same describe › "a current alpha member gets the same: alpha's organization-scoped junction rows bind nothing"
Positive control: the set the everyone definition names reaches both members Both cases above assert exactly ['global_everyone_default']; also resolve-authz-context.grant-set-by-name.test.ts › "the everyone definition's set resolves; a junction row binding another set confers nothing"
Why the cross-organization leak cannot come back resolve-authz-context.grant-set-by-name.test.ts › "the junction, the id bridge and the catalog rows are never read". The resolver reads none of sys_position, sys_position_permission_set or sys_permission_set.

Run here: pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/security/resolve-authz-context.test.ts src/security/resolve-authz-context.grant-set-by-name.test.ts. Result: 2 files and 120 tests passed, including all four named cases.

Case 6 used a real driver to pin how the engine's scope and the driver's predicate combined during a sys_position sweep. The resolver no longer reads that table (the last row above), so there is nothing of that kind left to pin.

No case of position-catalog-refusal.test.ts was deleted: it had 25 cases before and has 25 after. The hint assertions changed along with the behaviour they pinned, and now expect the generic remedy. The walled case that used a name only one organization's row carried now compares two names the catalog does not hold. The walled record-id case gained one assertion: it reads no sys_position.

Reverse verification

This was a one-shot check at 7427cac5, run through scripts/ablation-replace.mjs. For each leg the anchor hit exactly once and the file's blob hash changed. After each leg the restore was proven: the blob equals HEAD and git diff HEAD is empty. The suite imports the subject by relative source path (./security-plugin.js), so there is no dist/ step.

  • Leg 1: constraint.target set back to 'sys_position'. Result: 2 failed, 23 passed. The failures are exactly the two constraint pins (- "target": "position" / + "target": "sys_position").
  • Leg 2: the swallowed sys_position record-id read planted back before the throw. Result: 1 failed, 24 passed. The failure is the walled record-id case: expected [ 'sys_position', 'sys_position' ] to not include 'sys_position'.

Tests and gates (at 2fb7b362)

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 (the package's test script is vitest run): 187 files passed; 3736 tests passed and 45 skipped. Lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-security typecheck: lock VERDICT command-exit 0.
  • Before the change, src/position-catalog-refusal.test.ts and src/per-organization-catalog.test.ts passed 26 of 26. After it, src/position-catalog-refusal.test.ts passes 25 of 25.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 88 families. I ran all 88 at 2fb7b362 after a full turbo run build and recorded each exit code before any pipe: all 88 exited 0.
  • --ran reconciliation: ✓ dispatch-gates --ran: 88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED.
  • pnpm check:pm-dispatch-gates ran detached, as its header asks, and exited 0.
  • --ran also flagged that origin/main moved 6 commits during the run (to c4526a70). None of those commits touches plugin-security or any file in this diff. CI runs the merge ref.
  • Changed lines: 436 (87 added, 349 deleted) across 6 files, under the 3,000-line threshold.

Acceptance notes

  • packages/plugins/plugin-security/src/security-plugin.ts:4317 still says the refusal fires when a position "names no sys_position catalog row". In fact the verdict reads the security catalog. That file is outside this claim's file surface, so I did not edit it. Carrier: none.
  • Row 23b of content/docs/permissions/system-context.mdx still mentions "a name only a sys_position row carries". That is still true of rows left in an existing database, so I left it.
  • Lines 44–49 of the position-catalog-refusal.ts module doc describe the verdict against leftover sys_position rows. They are still accurate, so I left them.

Generated by Claude Code

… the retired catalog objects

The suite booted `sys_position`, `sys_permission_set` and
`sys_position_permission_set` from the legacy testkit so it could seed rows
the production composition can no longer hold: none of the three is
registered on main, and neither the refusal's verdict nor the authorization
resolver reads them. Booted as production boots, every case still passes
against the unchanged source; the two cases that asserted the id-to-name
hint now read the generic remedy, because the hint's `sys_position` read
answers OBJECT_NOT_FOUND and is swallowed.

Claude-Session: https://claude.ai/code/session_01Y5583bs2RDd53MH6kM1ca3
Co-authored-by: Claude <noreply@anthropic.com>
…able and names the catalog type it judged against

`idSpellingHints` looked a refused value up as a `sys_position` record id to
offer that row's name. `sys_position` is unregistered on main, so the read
answered OBJECT_NOT_FOUND, the catch swallowed it, and the hint was never
offered: it is deleted, with the record-id read context and the hint arm of
the message. The envelope's `constraint.target` named the same retired table;
it now names the security catalog type the value is judged against,
`position`, as the sibling permission-set grant refusal names `permission`.

The suite pins both: the two `constraint` assertions read `position`, and a
record id refused from inside an organization reads no `sys_position`.

Claude-Session: https://claude.ai/code/session_01Y5583bs2RDd53MH6kM1ca3
Co-authored-by: Claude <noreply@anthropic.com>
…, which nothing imports

`per-organization-catalog.ts` was left holding one export,
`rowMatchesDeclaration`, after the retired seeders that called it were
deleted; nothing imports it. Its suite kept one resolver case (case 6),
which never touched the module: it pinned `resolveUserAuthzGrants` against
`sys_position` and junction rows booted from the legacy testkit. Core's own
resolver suites pin the same three assertions against the resolver's source:
a member outside the binding organization collects nothing an organization
bound, a member inside it collects nothing either, and the definition's set
reaches both. They also pin that the resolver reads none of the retired
catalog tables at all.

The receiver-detach gate's must-not-fire control cited the deleted file as
the measured home of the `logger?.error?.(message)` spelling; it now cites
a live site of the same spelling. Changeset for the package.

Claude-Session: https://claude.ai/code/session_01Y5583bs2RDd53MH6kM1ca3
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 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/authorization.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/permissions/positions.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/permissions/system-context.mdx (via assertPositionNamesCatalogRow (symbol, a top-level function), sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/protocol/backward-compatibility.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))

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

  • content/docs/releases/index.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v13.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v14.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v15.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v17/17-1.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))
  • content/docs/releases/v17/17-2.mdx (via sys_position (literal, a string literal in POSITION_CATALOG_OBJECT))

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 — 15 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 c4526a70557f34fa8717a4802dc8dddae3237fd7 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 c4526a70557f34fa8717a4802dc8dddae3237fd7 → 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 at head 2fb7b3621e

Epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve), seat epic:#15194, under claims 6251403239 (#15196) and 6251408540 (#15204) · 2026-10-18T16:12Z. Read against GitHub and origin/main, not the report (os-dev-report on #15196).

Review route. Clause-②: no. The PR touches no packages/spec/src/** path and no governed text, and it declares no narrowing. So this is a seat read plus CI, with no second agent.

Checklist

  • Form. Draft, base main, assignee os-steve. Line 1 is Fixes #15196, line 2 is Part of #15204, line 3 is Clause-②: no. A closing-keyword scan of the whole body finds only Fixes #15196.
  • Scope. 6 files, +87 / −349. check-governed-merges --pr 24365: 0 of 6 paths governed, 436 changed lines (≤ 3,000). Nothing under content/docs/releases/ and no CHANGELOG.md. Every file is on the claimed surface.
  • Changeset (.changeset/15196-retired-catalog-residue.md, @objectstack/plugin-security patch). I read each sentence against the diff:
    • "constraint.target now reads position … instead of sys_position" matches position-catalog-refusal.ts's constraint: { target: POSITION_CATALOG_TYPE, targetField: 'name' }.
    • "The refusal no longer looks a refused value up as a sys_position record id" matches the removal of idSpellingHints and catalogReadContext.
    • "the unused internal per-organization-catalog.ts module" matches the deleted file.
    • patch is right. The removed and re-signed exports (idSpellingHints, POSITION_CATALOG_OBJECT, positionNotInCatalogMessage, positionNotInCatalogError, rowMatchesDeclaration) have zero references on origin/main outside their own files. plugin-security's exports map is '.' only, and its index.ts imports neither module, so none of them is on the published entry.
  • Refusal assertions. The rewritten suite asserts [code, status] = ['VALIDATION_FAILED', 400] and the reference_not_found entry at position, with the new constraint.
  • Deletion as dead code. The report's probe answers OBJECT_NOT_FOUND for find('sys_position') on main. I confirmed the quoted names above on origin/main.
  • Test deletion (AGENTS.md rule). Case 6 is deleted against duplicates named in the body. I spot-checked two on origin/main: resolve-authz-context.grant-set-by-name.test.ts:138 ("the junction, the id bridge and the catalog rows are never read"), and the §6a removed-member case in resolve-authz-context.test.ts.
  • CI on this head (read at this write): Governed Surface Queue Guard success. The 18 still pending include Test Core 1–6, Build Core and the Lint and Type Check jobs. 13 success, 6 skipped. Landing waits for every check to be green.

Accepted deviations. The dev discarded its first gate pass (three families exited 3 for missing prerequisites); the second pass read 88 of 88 families with exit 0, reconciled with --ran. It opened the PR one push before its final head, then pushed at once. Neither changes the delivered tree.

Out of scope, routed.

Landing. Not governed and under 3,000 lines, so ordinary queue landing once every check on this head is green: pr_ready then automerge_enable through the relay. At merge, #15196 closes through Fixes. #15204 then closes with its pointer: stage 9 is #23952, and Choice 4A is #11979, C8 stage 8b-0a.

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

Development

Successfully merging this pull request may close these issues.

feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4)

2 participants