Skip to content

fix(plugin-security)!: a package-declared position is refused at the data door, as the metadata door already refuses it - #22378

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22360-declared-position-provenance
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22360-declared-position-provenance

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22360

Clause-②: no (narrowing)

Rework round 1 — supersedes the sections below where they disagree

Seat REWORK 6071670550 on #22360. Head c345f93b73, which merges origin/main 16096e8d7b.

  • What changed from round 1. assertSystemRowWriteGate (security-plugin.ts, the gate region only) gains carve-out (d). An update of a sys_position row stamped package (or config, its legacy pre-A4 spelling, which SYSTEM_ROW_PROVENANCE guards as the same owner) passes when its patch is ONE object naming only active and/or is_default (PACKAGE_POSITION_ROW_STATE_COLUMNS). This matches permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的 active 存储列喂进了 #4001 之后严格化的 permission spec #4669's carve-out for packaged permission sets and 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) #15196's Q2 = A.
    • A filtered or multi-row row-state patch passes over package rows. A built-in in the filter still refuses it.
    • Still refused, with today's code and message:
      • a definition column (name, label, description, delegatable), any other column, a mixed or empty patch;
      • delete, transfer, restore and purge;
      • every write to a platform row (even a bare { active });
      • sys_capability;
      • the provenance-stamp refusal (a).
  • H1, this branch: on a package-declared position, Deactivate, Activate and Set as Default answer 200 and persist across a reboot. A label or delegatable edit answers 403, and so does a delete.
  • Pins:
    • 13 gate cases in store-fault-fail-closed.test.ts, on its ledgered double. The ablation of the carve-out turns exactly the 3 admit cases red.
    • The dogfood pin is rewritten: 10/10 with the change. Against main's build, 5 are red. The dist ablation turns 3 red.
  • Changeset: definition edits (including delegatable) and delete are refused; row state stays switchable; the remedy stays.
  • Verification:
    • plugin-security 188 files / 3915 tests passed;
    • the position dogfood files 18/18 (171 tests);
    • gates 70 of 70 derived and run, with --ran a derived zero.
  • Acceptance note 1 ("the narrowing is wider than…") is resolved by this carve-out.

What this changes

The declared-position seeder (bootstrapDeclaredPositions, @objectstack/plugin-security) now stamps managed_by: 'package' on the row of a position a code package holds:

  • on insert;
  • on an existing row that carries no managed value: an upgraded deployment's row, stamped admin by the object default. That write carries managed_by and nothing else, apart from the label and description refresh the seeder always made.

"A code package holds the name" is asked of the engine registry's artifact lookup (getArtifactItem). That is the lookup the metadata door refuses a save from with 403 NOT_OVERRIDABLE, so the data door now refuses what the metadata door refuses, and nothing more. assertSystemRowWriteGate is unchanged: it already refuses an admin-door update or delete of a package row, as it does for the built-ins. A position the environment authored through the metadata door has no package, so its row stays unmanaged.

Diff: bootstrap-declared-positions.ts, its unit test, one dogfood pin, one changeset. Nothing in packages/spec, packages/core, the gate, the built-in seeder, the permission-set seeder, or the in-flight position write-through of #15196 stage S7.

Reproduction (H1), on origin/main b460153912

Showcase, single posture, two boots of one database file. Scratch probe, deleted and never committed.

step main this branch
declared rows (10) managed_by: admin, organization null managed_by: package
catalog entry for each source registry, package com.example.showcase (= getArtifactItem) same
PUT /meta/position/manager 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE
PATCH /data/sys_position/ID label of manager 200, row relabelled; next boot restores the declared label 403 PERMISSION_DENIED, row unchanged
same, active: false on contributor 200, persists across the reboot 403 PERMISSION_DENIED
same, delegatable: true on exec 200, persists across the reboot 403 PERMISSION_DENIED
DELETE on auditor 200; the next boot creates the row again 403 PERMISSION_DENIED
Setup-created position: create, then edit 201, 200 201, 200
built-in everyone label edit 403 PERMISSION_DENIED 403 PERMISSION_DENIED

assertSystemRowWriteGate judged the declared rows as admin-authored before (managed_by: admin is not in its managed set) and judges them package now.

The stamp and #2909 T2 (H2)

T2's text locks that a re-seed only refreshes label/description and never touches the authoritative fields (bindings, delegatable, and the like). The stamp does not conflict with it:

  • On a row the seeder creates, the stamp sets provenance. It projects no declaration content, because a position declaration has no managed_by key.
  • On an existing row it overwrites no administrator edit. managed_by is readonly, and the gate refuses an admin-door payload naming platform/package, so admin on a declared row was only ever the object default. The authoritative columns keep their values (pinned: active, is_default, delegatable survive the re-stamp).

Value: package, the spelling the gate refuses and the one the permission-set and capability seeders stamp. It needs nothing from normalize-managed-by.ts: package is canonical and the normalizer only rewrites system/config/user. A row already carrying a gate-managed value (platform, package, legacy system/config) is never re-stamped.

Measured: a Setup-created position whose name a package declares later gets locked. A Setup row (p22360_taken, organization-bound) was created and edited (200). A third boot, whose stack declares that name, then ran the seeder. On main the seeder relabelled the row and left it admin, so the next edit answered 200. On this branch the row is re-stamped package and the next edit answers 403 PERMISSION_DENIED. The seeder matches by name, and it already took such a row over for its label; the fix was not widened (see Acceptance notes).

Readers of sys_position.managed_by (H3)

  • assertSystemRowWriteGate: the one answer that changes. It refuses update, delete, transfer, restore and purge on the stamped rows, and a filter-scoped write whose filter matches one.
  • the reserved_identity_name validation rule on sys_position: it exempts only platform/system, so its answer is unchanged. The seeder never writes a built-in name.
  • normalize-managed-by.ts: scans legacy values only. Unchanged.
  • uninstall cleanup (cleanup-package-permissions.ts, the only plugin-security entry on the protocol's uninstall seam; the other entry is runtime's package jobs): it selects sys_permission_set by package_id + managed_by: 'package', and deletes sys_position_permission_set by permission_set_id and sys_audience_binding_suggestion by package_id. It never reads or deletes sys_position, so it deletes no row it did not delete before.
  • explain engine, delegated-admin gate, resolve-authz-context, sharing services: they read sys_position (name, active, organization) but never managed_by.
  • Setup UI: managed_by is in the object's highlightFields, so the record header now reads Package. The Activate, Deactivate and Set as Default actions carry no managed_by condition and answer 403 on these rows, as they already do on a built-in. objectui at the pinned .objectui-sha a58626c8: the only row-level managed_by reader is recordDelete, and it acts on sys_permission_set only.

Pins: red on main, green with the change, ablations

Unit (src/bootstrap-declared-positions.test.ts, source-resolved, real SchemaRegistry), 7 new cases:

  • The seeder source reverted to the b460153912 blob (on-disk hash checked) gives 4 failed, 16 passed: a new row stamped; an upgraded admin row corrected with exactly { id, managed_by }; display refresh and stamp in one write; the no-artifact-lookup registry branch. The other three are controls that pass on main by design: a second pass writes nothing; a gate-managed value is kept; an environment-authored definition stays unmanaged.
  • Ablating only the re-stamp line (node scripts/ablation-replace.mjs, anchor 1 to 0, blob 85be9d2d to cff57413) gives 2 failed, 18 passed: exactly the two re-stamp cases.
  • Both restores were proved by blob equal to HEAD and an empty git diff HEAD. The tree with the change gives 20 passed.

Dogfood (declared-position-provenance.dogfood.test.ts; plugin-security resolves through dist/):

  • Against the pre-change dist/ (ablation-dist-preflight --absent passed on the new marker): 3 failed, 4 passed. The red cases are the declared row's provenance, the refused edit with the row unchanged, and the cold boot where the upgraded row is re-stamped and refused. The 4 controls pass: the precondition (NOT_OVERRIDABLE at the metadata door), the administrator-authored position, the built-in refusal, and the administrator- and environment-authored rows after the boot.
  • Ablating the re-stamp line, then rebuilding (dist/ reading: the call is absent) gives 1 failed, 6 passed: the cold-boot case.
  • Restore leg: rebuild, the call is present in 2 built files, 7 passed.

Changeset

.changeset/22360-declared-position-package-provenance.md: @objectstack/plugin-security: minor. It carries a BREAKING banner, Clause-②: no (narrowing), the remedy (change it in the package, or clone it under a new name), and ADR-0087 not-required (no-migration-prescription). The breaking change ships as minor under the launch-window convention. Verdict lines:

  • check-changeset-no-major --base origin/main: "✓ This diff introduces no major bump."
  • the same run with a pull_request event carrying this body's declaration line: "✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch."
  • check-adr-0087-registration --base origin/main: "✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition."

Verification (HEAD 8c3e68a2b5)

  • @objectstack/plugin-security full suite: 186 files, 3891 passed, 45 skipped. typecheck passed (tsc on src and scripts, plus check:test-typecheck; --listFiles counts the edited test).
  • dogfood, every file that reads or writes positions (18, including the new pin): 168 passed. @objectstack/dogfood typecheck passed (--listFiles counts the pin).
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 69 commands, identical to the claim's reading, all exit 0. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 packages had no dist/); after building them (turbo, all cached) it answered exit 0. --ran: "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN".
  • pnpm check:error-status-conformance (by hand): "✓ every derivable runtime status is documented, and every documented status is reachable."
  • eslint, narrowed to the 3 changed TypeScript files: --format json reports 3 files, 0 errors, 0 warnings. --print-config shows none ignored and no parserOptions.project/projectService, so type-aware linting is off and this diff cannot move a verdict on any file it does not touch.
  • Not merged with origin/main: the 2 commits since the branch point touch service-storage, the spec error-code ledger and one storage dogfood file, none of this diff's packages or behaviour.

Acceptance notes

  • The narrowing is wider than "accepted, then reverted". With the gate unchanged, every admin-door update of a package-declared row is refused, not only the label and description. The measured active and delegatable edits used to persist. So Setup's Activate, Deactivate and Set as Default on such a position now answer 403, as they do on a built-in, and so does a delete. The changeset says so. The triage ruling covers delegatable explicitly, and ADR-0131 D6/D3 (managed items read-only, clonable) covers the rest; the report raises it for the seat to confirm.
  • A Setup-created position later declared by a package becomes the package's (measured above). Not widened, per the order.
  • objects/sys-position.object.ts (the comment above reserved_identity_name) still says the declared seeder "stamps no provenance at all (the row defaults to admin)". The rule's verdict is unchanged, because package is not exempt, but that premise is now stale. The file is outside this PR's fence and is left as is.
  • 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) #15196 S7's branch (6d127ed8c1, no PR yet) has a dogfood case, "Q2", that expects PATCH of manager's label to answer 200. With this change on main it answers 403 PERMISSION_DENIED. Whichever lands later reconciles.

Generated by Claude Code

claude added 3 commits October 8, 2026 23:33
…-held declared positions

The declared-position seeder wrote its rows with no provenance stamp, so
they carried the object default (admin) and the system-row write gate did
not protect them: a data-door edit of a package-declared position's label
answered 200 and the next boot wrote the declaration back over it. The
metadata door refuses the same edit (403 NOT_OVERRIDABLE).

A position a code package holds -- asked of the registry's artifact
lookup, the one the metadata door refuses a save from -- is now stamped
managed_by 'package' on insert, and an existing row lacking a managed
value (an upgraded deployment's) is corrected in place, managed_by and
nothing else. The gate is unchanged. An environment-authored definition
keeps an unmanaged row.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…r and keeps its declared label across a cold boot

Over the real showcase composition under single, two boots of one database
file: the declared row carries package provenance, a data-door label edit
is refused with the gate's code and leaves the row unchanged, an upgraded
deployment's admin-stamped row is re-stamped at boot and refused the same
way. Controls: administrator- and environment-authored positions stay
editable, a built-in's refusal is unchanged, and the seeder's own system
write still refreshes a declared row's display text.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ge-declared position

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

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

22 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 117d34de3fe5af61f684d7ab5d24c4dadebdda97.

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

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 — 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 117d34de3fe5af61f684d7ab5d24c4dadebdda97 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 117d34de3fe5af61f684d7ab5d24c4dadebdda97

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

…sition passes the system-row write gate

A package position row is locked the way a packaged permission set is: a
patch touching only active and/or is_default on a sys_position row
stamped package (or its legacy config spelling) passes, single-id or
filtered, as permission-set-projection's write door passes a bare
{ active } patch on a packaged set. Definition columns (name, label,
description, delegatable), any other column, delete and the other
lifecycle verbs stay refused with the gate's code and message. Platform
rows and sys_capability get no carve-out; the provenance-stamp refusal
is unchanged.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Oct 9, 2026
claude added 2 commits October 9, 2026 00:27
…ersists while its definition stays locked

Deactivate, Activate and Set as Default answer 200 and persist across a
cold boot; a label edit, a delegatable edit and a delete answer 403
PERMISSION_DENIED with the row unchanged; an updateMany row-state patch
lands and an updateMany label patch is refused; a built-in refuses even a
bare { active } patch. The changeset now states that definition edits and
delete are refused and row state stays switchable.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…row-state carve-out

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… gate's #7505 cases, on its ledgered store double

A new test file carried a second findOne engine double the pinned ledger
does not record; housing the cases in store-fault-fail-closed.test.ts,
which already tests assertSystemRowWriteGate over a ledgered double,
covers the same carve-out without a ledger change.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 01:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 01:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 00bee27 Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22360-declared-position-provenance branch October 9, 2026 02:13
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/l tests tooling

Projects

None yet

2 participants