Repository navigation
feat(plugin-security)!: under single, a Setup position create or rename into a name a package or a built-in holds answers the metadata door's refusal (C2 stage S10) - #22582
Conversation
… a package or a built-in holds answers the metadata door's refusal (C2 stage S10) Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…amespace narrowing Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…ition create or rename it guarded Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aae3e9dbc2f9b761eec36613b59e820ccc0302e0 && git checkout aae3e9dbc2f9b761eec36613b59e820ccc0302e0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96e4be482987d0acd55e7b9c3c957580fe7c21d8 ecf916430310806abd41d2a2e9171f8acb288e97 && git checkout -B drift-repro 96e4be482987d0acd55e7b9c3c957580fe7c21d8 && git merge --no-ff ecf916430310806abd41d2a2e9171f8acb288e97
node scripts/docs-audit/affected-docs.mjs --json 96e4be482987d0acd55e7b9c3c957580fe7c21d8
|
Contract reviewServed-tier: Read: card #15196's body and all 59 comments (the census Form. Draft; head repo equals base repo; line 1 Gates on the head. Read complete at the time stamped above: 35 check-runs, none still running. All seven required contexts conclude ① Derived judgmentsEach row is an accept-set or surface change the diff implies, read off
② Semver level
③ Boundary flagsDev report
Its four out-of-scope findings:
The claim's exclusions ( Escalated by this review (neither moves the verdict; each names its carrier):
Breaker readings: none. No seeded row is deleted; no driver arm is touched; nothing widens. Implemented-by: VERDICT: PASS Generated by Claude Code |
Part of #15196
Clause-②: no (narrowing)
C2 stage S10, one namespace for positions (census
6038897018, S10 row). #15196 remains open for the other stages (S5c, S8a/S8b, S9); this PR carries no closing keyword.What changes
Under
single, a non-system Setup create of asys_positionrow, or a rename into a name, that a package or a built-in already holds is now refused. The refusal is the metadata door's own:403 NOT_OVERRIDABLE, the same refusalPUT /api/v1/meta/position/NAMEalready gives that name. No row is kept, a renamed row keeps its old name, and the renamed position's definition is untouched.ObjectStackProtocolImplementation.packagedBaseRefusal({ type: 'position', name, operation: 'save' }). That method is the metadata door's own locked-base verdict, without a write. It uses the predicatesaveMetaItemuses (isSealedManagedItem, which readsSchemaRegistry.getArtifactItem) and the same emitter. The write-through relays the refusal as the door built it: code, status and sentence. It stamps no code of its own and adds no second predicate. The/automationdoors ask the same method the same way (refusePackagedFlowBaseChange,packages/runtime/src/domains/automation.ts). Nothing is written to environment metadata for such a name, whatever the answer.400 VALIDATION_FAILED, and a name already held in the same organization stays409 UNIQUE_VIOLATION. Only a row the engine accepted is asked about, and the S7 undo removes it, or restores it on a rename, when the door refuses.packagedBaseRefusalkeeps the S7 stand-down, as the/automationdoors keep theirs.Files (all within the claim's surface,
packages/plugins/plugin-security/, plus the changeset):src/position-write-through.ts: the refusal, its module note, and the undo-failure log's remedy, which now also says "not a name a package or a built-in already holds".src/position-write-through.test.ts: the S10 pins, and the Q2 = A stand-down test narrowed to edit and delete. Its create-under-everyonepass-on case now lives in the S10 block as a refusal.src/security-plugin.ts: the registration comment only, no code..changeset/15196-s10-position-namespace.md:minor, BREAKING narrowing, FROM → TO, and the remedy (rename the position).Measured first, per posture, both doors (hypothesis 1)
These readings come from a local probe, never committed and deleted after the run. It booted the real showcase composition through
@objectstack/verify(bootShowcase), signed in as the seeded platform admin, and used the HTTP doors. Thegroupposture ran withOS_TENANCY_POSTURE=groupplusmultiTenant: 'posture-only', andisolatedran withmultiTenant: 'posture-only'. Both reporteddegraded: false. Under each walled posture the admin created an organization and made it active first.Before is
origin/main6a3f82efa7. It was reproduced a second time by the dist ablation below, with the probe reordered so that both renames run before any create. After is this branch: first91177a22, then re-read at the final headecf9164303, with the same answers.singlebeforesingleaftergroup(before = after)isolated(before = after)POST /data/sys_positionmanager(packagecom.example.showcase)managerrow lands in the default organization beside the package's organization-less rowNOT_OVERRIDABLE, no row keptUNIQUE_VIOLATIONUNIQUE_VIOLATIONeveryone(built-in anchor)NOT_OVERRIDABLEUNIQUE_VIOLATIONUNIQUE_VIOLATIONguest(built-in anchor)NOT_OVERRIDABLEUNIQUE_VIOLATIONUNIQUE_VIOLATIONorg_admin(reserved identity)VALIDATION_FAILEDVALIDATION_FAILEDVALIDATION_FAILEDVALIDATION_FAILEDPATCHrename of an admin position intoexec(package)NOT_OVERRIDABLE, row and definition keep the old nameUNIQUE_VIOLATIONUNIQUE_VIOLATIONguest(built-in)NOT_OVERRIDABLEUNIQUE_VIOLATIONUNIQUE_VIOLATIONPUT /meta/position/auditor(package)NOT_OVERRIDABLENOT_OVERRIDABLENOT_OVERRIDABLENOT_OVERRIDABLEPUT /meta/position/everyone(built-in)NOT_OVERRIDABLENOT_OVERRIDABLENOT_OVERRIDABLENOT_OVERRIDABLEPUT /meta/position/s10_studio_lead(control)singleonly. Undersinglethe declared and built-in rows are organization-less, while an admin's Setup row is stamped with the Default Organization. Theunique: 'organization'index (COALESCE(organization_id, '__global__')) therefore never collided, and the write-through stood down for the name.GET /api/v1/meta/positionlists the held names (manager,exec,everyone,guest,org_admin) in all three postures. The changeset's remedy points there.Provenance (hypothesis 3)
Read off the booted showcase's engine registry, the same in all three postures:
getArtifactItem('position', NAME)._packageIdgetItem(...)._packageIdmanager,exec(stack-declared)com.example.showcasecom.example.showcaseeveryone,guest,org_admin(built-in)com.objectstack.plugin-securitycom.objectstack.plugin-securityThe carrier
6040687107/6040744949reading ("stack-declared positions carry no_packageId") was true on 2026-10-07. PR #22262 (0b997ea4, 2026-10-08) closed it by registering a stack'spositionsunder the owning package. Built-ins are registered under the security plugin's package id (stage S2). Both kinds are therefore held for the door, and the refusal treats them alike:403 NOT_OVERRIDABLEfrom one predicate. The four reserved identity names never reach the door: the engine's rule validator refuses the row first (400 VALIDATION_FAILED, the #15972 invariant), and this PR keeps that order.The other hypotheses
SchemaRegistry.getArtifactItemthrough the door's own verdict.packageHoldsPosition(the stand-down's read) is untouched, and it still decides the edit and delete stand-downs.NOT_OVERRIDABLE/ 403, the door's own. It matches the data door's existing pattern: S7's Q1 = A relays the door's own refusal,INVALID_REQUEST/INVALID_METADATA.NAMESPACE_CONFLICT/ 422 is the package-registration refusal (ADR-0048 addendum N.2). N.3 keeps an environment save over a package-held name outside N.2, under overlay precedence, where the door answersNOT_OVERRIDABLE. No new code.singleadmitted), so code was owed.Tests
All runs were at head
ecf9164303unless noted, throughscripts/pm/os-verify-lock.shwithNODE_OPTIONS=--max-old-space-size=3072and vitest--maxWorkers=2.pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2reported Test Files 192 passed (192), Tests 4044 passed, 45 skipped (4089), exit 0. The run started at 01:49:31Z. It includes the S5 grant-equivalence goldens.pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/position-write-through.test.tsatecf9164303reported Tests 41 passed (41), exit 0. This file was re-run at the final head because the full-suite run began 4 s before that commit was cut.pnpm --filter @objectstack/plugin-security typecheckrantsc --noEmit, thentsc -p tsconfig.scripts.json, thencheck:test-typecheck("0 file(s) / 0 error(s)"). Exit 0.ecf9164303afterturbo run build --filter=@objectstack/plugin-security, with the same answers. The data door's refusal body formanageris the door's own sentence, with"code":"NOT_OVERRIDABLE"and"object":"sys_position".turbo run build --filter=@objectstack/dogfood^...reported 63/63 successful (31 cached), exit 0.The S10 pins are in
position-write-through.test.ts, under the describe block[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace. The engine is real (better-sqlite3), theSecurityPluginis real, and so is the metadata door.everyoneand underguest: envelope{ code: NOT_OVERRIDABLE, status: 403 }, no row and no definition kept. No message pin, as the census ruled.saveMetaItemrefusal: code, status and message. This is an identity check against the door, not a message pin.guest: refused, and the row and its definition keep the old name.VALIDATION_FAILED; an edit that keeps a held name stays a row write; a door without the verdict keeps the stand-down.Ablation (remove the check, the write lands, red)
The fix was committed first (
91177a22). The mutation was made withscripts/ablation-replace.mjs, which uses an anchor hit, an on-disk count and a blob proof, and carries its own trap restore. InheldPositionNameRefusal, the guard line was replaced by one that returnsnullfor every real name, carrying the markerS10_ABLATION_MARKER.src/, so nodist/sits on the resolution path. Anchor x1 → x0, blob1acd4313e68c→ba75c2f33e43.src/position-write-through.test.tsran 7 failed | 33 passed (40). The 7 red tests are exactly the S10 refusal pins: three creates, the door-identity test, the bulk create, and two renames. The three controls stayed green. Restore: blob after restore == HEAD (1acd4313e68c), andgit diff HEADwas empty. The direction was the expected one (turned red).dist/and HTTP. Same mutation, thenturbo run build --filter=@objectstack/plugin-security(exit 0).node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security S10_ABLATION_MARKERfound the marker indist/index.jsanddist/index.mjs(exit 0). The showcase probe then read the pre-S10 answers back: createmanager201, createeveryone201, rename intoexec200, rename intoguest200, and the renamed position's definition deleted. Restore proven as in leg 1. A rebuild followed, and--absentgave distmarker absent from all 6 built files. Its first tree reading answered exit 3: the untracked local probe file was still in the tree, while the dist reading passed. After the probe was deleted, the rerun answered exit 0 (dist absent, tree clean against HEAD).Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths passed, on the real diff atecf9164303: 4 paths, 66 commands. Each was run with its exit captured before any pipe, then reconciled with--ran. Result: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN; all 66 recorded exit 0.pnpm check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET, exit 3: 8 unrelated packages had nodist/. Afterturbo run buildfor those 8 (57/57 tasks, all cache hits) it answered exit 0: "107 published require entry point(s) across 66 package(s) load".check-adr-0087-registration(and its--self-test),check-empty-changeset(and its--self-test),check-dts-emitted --self-test,release-rehearsal-clone --self-test,release-pending-publish --self-test,check:engine-double-contract,check:objectql-double-limit,check:objectui-changeset,check:pm-changeset-deadline-census,check:query-options-erasure,check:type-check-coverage,check:type-check-debtandcheck:where-matcher.pnpm lint. CI owns those.Acceptance notes
409 UNIQUE_VIOLATION, not the door's403 NOT_OVERRIDABLE. Undergroupandisolated, every organization holds its own seeded rows of the declared and built-in names, so the uniqueness index refuses first, and the write-through stands down for a walled posture. The name is refused in both walled postures, under another code. Carrier: S8, which carries the walled half under Q3 = A (release6080018188).singleare not swept. A Setup row created before this release under a package-held or built-in name (for example a default-organizationmanagerbeside the package's) keeps working. An edit that keeps its name stays a row write with no definition, and this PR deletes nothing (C7 owns deletions). Carrier: S8b (same-name rows rule,6075437096) and S9 (boot report).managedItemSealedSentence(packages/metadata-protocol/src/packaged-base-regime.ts), outside this claim's file surface, and is reported in the stage report.single, a reserved identity name answers400 VALIDATION_FAILED(the engine's rule validator, first), and an audience anchor (everyone,guest) answers403 NOT_OVERRIDABLE(the door). This is deliberate: the engine's checks keep precedence, as S7 established.6075437096) makes the refusal arrive before any row write, and deletes the undo paths this refusal reuses. Nothing in this PR adds undo code.grant-readers-by-name.golden.test.ts,grant-permission-set-name.equivalence.test.ts) ran inside the fullplugin-securitysuite above.plugin-securityonly. The HTTP-level readings above come from the local probe, which was deleted.Generated by Claude Code