Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-15196-s10-position-namespace
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-15196-s10-position-namespace

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 a sys_position row, 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 refusal PUT /api/v1/meta/position/NAME already gives that name. No row is kept, a renamed row keeps its old name, and the renamed position's definition is untouched.

  • Asked of its owner, never re-derived. The verdict is ObjectStackProtocolImplementation.packagedBaseRefusal({ type: 'position', name, operation: 'save' }). That method is the metadata door's own locked-base verdict, without a write. It uses the predicate saveMetaItem uses (isSealedManagedItem, which reads SchemaRegistry.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 /automation doors 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.
  • After the engine's own checks. The row is written first, as S7 does for every write. So a refusal the engine already makes keeps its own answer: a reserved identity name stays 400 VALIDATION_FAILED, and a name already held in the same organization stays 409 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.
  • A verdict the door cannot reach (it re-raises anything that is not its refusal) undoes the write too, and that failure is the answer.
  • Unchanged. An edit that keeps such a name and a delete still stand down, as Q2 = A ruled. Whether the data door admits those at all is the system-row gate's call. System writes, walled postures and kernels without a metadata door are unchanged. A door that brings no packagedBaseRefusal keeps the S7 stand-down, as the /automation doors 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-everyone pass-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. The group posture ran with OS_TENANCY_POSTURE=group plus multiTenant: 'posture-only', and isolated ran with multiTenant: 'posture-only'. Both reported degraded: false. Under each walled posture the admin created an organization and made it active first.

Before is origin/main 6a3f82efa7. 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: first 91177a22, then re-read at the final head ecf9164303, with the same answers.

Door / write single before single after group (before = after) isolated (before = after)
Setup POST /data/sys_position manager (package com.example.showcase) 201, a second manager row lands in the default organization beside the package's organization-less row 403 NOT_OVERRIDABLE, no row kept 409 UNIQUE_VIOLATION 409 UNIQUE_VIOLATION
Setup create everyone (built-in anchor) 201 403 NOT_OVERRIDABLE 409 UNIQUE_VIOLATION 409 UNIQUE_VIOLATION
Setup create guest (built-in anchor) 201 403 NOT_OVERRIDABLE 409 UNIQUE_VIOLATION 409 UNIQUE_VIOLATION
Setup create org_admin (reserved identity) 400 VALIDATION_FAILED 400 VALIDATION_FAILED 400 VALIDATION_FAILED 400 VALIDATION_FAILED
Setup PATCH rename of an admin position into exec (package) 200, and the renamed position's environment definition is deleted 403 NOT_OVERRIDABLE, row and definition keep the old name 409 UNIQUE_VIOLATION 409 UNIQUE_VIOLATION
Setup rename into guest (built-in) 200 403 NOT_OVERRIDABLE 409 UNIQUE_VIOLATION 409 UNIQUE_VIOLATION
Setup create of a fresh name (control) 201 201 201 201
Studio PUT /meta/position/auditor (package) 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE
Studio PUT /meta/position/everyone (built-in) 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE
Studio PUT /meta/position/s10_studio_lead (control) 200 200 200 200
  • The Studio half was already refused in every posture, so no metadata-door change was needed (hypothesis 1 holds).
  • The gap was the Setup data door under single only. Under single the declared and built-in rows are organization-less, while an admin's Setup row is stamped with the Default Organization. The unique: 'organization' index (COALESCE(organization_id, '__global__')) therefore never collided, and the write-through stood down for the name.
  • The walled postures already refuse, through the organization's own seeded rows, under the uniqueness code rather than the door's. This PR leaves the walled half alone: the write-through's own note gives it to a later stage. See the Acceptance notes.
  • GET /api/v1/meta/position lists 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:

Name getArtifactItem('position', NAME)._packageId getItem(...)._packageId
manager, exec (stack-declared) com.example.showcase com.example.showcase
everyone, guest, org_admin (built-in) com.objectstack.plugin-security com.objectstack.plugin-security
a fresh Setup name none none

The carrier 6040687107 / 6040744949 reading ("stack-declared positions carry no _packageId") was true on 2026-10-07. PR #22262 (0b997ea4, 2026-10-08) closed it by registering a stack's positions under 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_OVERRIDABLE from 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

  • 2, the provenance read. The refusal reads SchemaRegistry.getArtifactItem through the door's own verdict. packageHoldsPosition (the stand-down's read) is untouched, and it still decides the edit and delete stand-downs.
  • 4, the envelope. The code is the registered 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 answers NOT_OVERRIDABLE. No new code.
  • 5. Not every posture refused (single admitted), so code was owed.

Tests

All runs were at head ecf9164303 unless noted, through scripts/pm/os-verify-lock.sh with NODE_OPTIONS=--max-old-space-size=3072 and vitest --maxWorkers=2.

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 reported 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.ts at ecf9164303 reported 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 typecheck ran tsc --noEmit, then tsc -p tsconfig.scripts.json, then check:test-typecheck ("0 file(s) / 0 error(s)"). Exit 0.
  • The HTTP probe (table above) was re-run at ecf9164303 after turbo run build --filter=@objectstack/plugin-security, with the same answers. The data door's refusal body for manager is the door's own sentence, with "code":"NOT_OVERRIDABLE" and "object":"sys_position".
  • Build: 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), the SecurityPlugin is real, and so is the metadata door.

  • A create under a package-declared name, under everyone and under guest: envelope { code: NOT_OVERRIDABLE, status: 403 }, no row and no definition kept. No message pin, as the census ruled.
  • The data door's refusal equals the door's own saveMetaItem refusal: code, status and message. This is an identity check against the door, not a message pin.
  • A bulk create carrying one held name is refused whole.
  • A rename into a package-held name, and into guest: refused, and the row and its definition keep the old name.
  • A verdict the door cannot reach undoes the create and the rename.
  • Three controls: the reserved identity name keeps the engine's 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 with scripts/ablation-replace.mjs, which uses an anchor hit, an on-disk count and a blob proof, and carries its own trap restore. In heldPositionNameRefusal, the guard line was replaced by one that returns null for every real name, carrying the marker S10_ABLATION_MARKER.

  • Leg 1, unit suite. The subject is imported relatively from src/, so no dist/ sits on the resolution path. Anchor x1 → x0, blob 1acd4313e68c → ba75c2f33e43. src/position-write-through.test.ts ran 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), and git diff HEAD was empty. The direction was the expected one (turned red).
  • Leg 2, dist/ and HTTP. Same mutation, then turbo run build --filter=@objectstack/plugin-security (exit 0). node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security S10_ABLATION_MARKER found the marker in dist/index.js and dist/index.mjs (exit 0). The showcase probe then read the pre-S10 answers back: create manager 201, create everyone 201, rename into exec 200, rename into guest 200, and the renamed position's definition deleted. Restore proven as in leg 1. A rebuild followed, and --absent gave dist marker 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 at ecf9164303: 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.

  • One prerequisite rerun. pnpm check:dual-build-cjs-loads first answered PREREQUISITE NOT MET, exit 3: 8 unrelated packages had no dist/. After turbo run build for those 8 (57/57 tasks, all cache hits) it answered exit 0: "107 published require entry point(s) across 66 package(s) load".
  • Families beyond the dispatch list, derived from the actual diff and run: 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-debt and check:where-matcher.
  • Not run here: repo-wide scans such as pnpm lint. CI owns those.

Acceptance notes

  • The walled half answers 409 UNIQUE_VIOLATION, not the door's 403 NOT_OVERRIDABLE. Under group and isolated, 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 (release 6080018188).
  • Same-name rows that already exist under single are not swept. A Setup row created before this release under a package-held or built-in name (for example a default-organization manager beside 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).
  • The relayed sentence is the door's. The door words it for an edit ("sealed against in-place edits … Edit the source artifact and redeploy"), and a create receives the same sentence. For an administrator who wants a position of their own, the useful remedy is to choose another name. The changeset states that remedy. The sentence lives in managedItemSealedSentence (packages/metadata-protocol/src/packaged-base-regime.ts), outside this claim's file surface, and is reported in the stage report.
  • Two refusal codes for built-in names, by order. Under single, a reserved identity name answers 400 VALIDATION_FAILED (the engine's rule validator, first), and an audience anchor (everyone, guest) answers 403 NOT_OVERRIDABLE (the door). This is deliberate: the engine's checks keep precedence, as S7 established.
  • S8's metadata-first switch (6075437096) makes the refusal arrive before any row write, and deletes the undo paths this refusal reuses. Nothing in this PR adds undo code.
  • The grant-equivalence goldens from S5 (grant-readers-by-name.golden.test.ts, grant-permission-set-name.equivalence.test.ts) ran inside the full plugin-security suite above.
  • No dogfood pin is committed. The claim's surface is plugin-security only. The HTTP-level readings above come from the local probe, which was deleted.

Generated by Claude Code

… 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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/system-context.mdx (via createPositionWriteThrough (symbol, a top-level function))
What this run could not see
  • 1 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 96e4be482987d0acd55e7b9c3c957580fe7c21d8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from aae3e9dbc2f9b761eec36613b59e820ccc0302e0 — the merge of head ecf916430310806abd41d2a2e9171f8acb288e97 into base 96e4be482987d0acd55e7b9c3c957580fe7c21d8, 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 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

⚠️ 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 96e4be482987d0acd55e7b9c3c957580fe7c21d8 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ecf916430310806abd41d2a2e9171f8acb288e97
Local-runs: none

Read: card #15196's body and all 59 comments (the census 6038897018 with its S10 row, the seat verdict 6039027082, the maintainer's ruling 6050490870 Q3 A / Q4 A, the S7 measurement round 6070148106, the S7 re-rule 6070208925, the S7 contract review 6072809773 and ACCEPT 6072828369, the S7 shape ruling 6075437096, the S7 release 6080018188, the S5a records 6082871036 / 6083715140 / 6084206172, the S10 claim 6092128183, the S10 dev report 6092644984); PR #22582's body, its 4-file list and the net diff against main at the head (4 files, +231 / -21, equal to the file list); the check-runs on the head. The governing texts (ADR-0048 addendum N.1 to N.4, ADR-0131 D3, ADR-0005 overlay precedence) and the registry, protocol and plugin sources were read on origin/main, never run. Rendered at 2026-10-10T02:35Z. ⛔ Classes, positions and functions only.

Form. Draft; head repo equals base repo; line 1 Part of #15196, line 2 Clause-②: no (narrowing); no closing keyword; the PR assignee equals the card's. Not governed: the four paths are .changeset/ and packages/plugins/plugin-security/src/, and none is a GOVERNED_SURFACES row; 252 changed lines. origin/main is one commit past the PR base (25be87612d, the lifecycle-verb explain fix): it shares one file with this diff, security-plugin.ts, in a different region (its step 2.7 gate against this PR's registration comment near the write-through wiring), and GitHub reports the PR mergeable; the queue rebuilds the merged generation.

Gates on the head. Read complete at the time stamped above: 35 check-runs, none still running. All seven required contexts conclude success: Lint & Repo Gates, TypeScript Type Check (and its four legs), Test Core (and its six shards), Dogfood Regression Gate (and its three shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also success: Check Changeset, Check PR Size, Check Documentation Links, Dogfood Verify CLI, Flag docs affected by code changes, Auto Label, the three card and claim checks and the single-writer-path check. Rostered skips: Build Docs, Console Pin Gate, Packed-tarball smoke (path filter or opt-in). No failure on the head. Those conclusions are the gate verdicts; nothing was built, run or re-run here.

① Derived judgments

Each row is an accept-set or surface change the diff implies, read off position-write-through.ts against its main version, the test file, the one security-plugin.ts hunk and the changeset, and judged against the rulings rather than the body.

  1. Data door, single, non-system caller, a door that brings packagedBaseRefusal: a sys_position insert whose name a package or a built-in holds. Before: the engine accepted the row (under single the held row is organization-less and the administrator's row carries the Default Organization, so the unique: 'organization' index never collided) and the write-through stood down for the name. Now: the row is written, every created row is read back, heldPositionNameRefusal asks door.packagedBaseRefusal({ type: 'position', name, operation: 'save' }), the refusal is thrown before any definition is saved, and undoInsert(written) removes every row of the write under the system context. Envelope 403 NOT_OVERRIDABLE, the door's own sentence. RIGHT: the census's S10 row ("Setup refuses a position name a managed package holds"), executed under Q4 = A as ADR-0048 addendum N.2 records the holder set (an installed package, the environment catalog, a built-in). The envelope is N.3's, not N.2's: a package-less environment save over a package-held name stays under ADR-0005 overlay precedence, position is allowOrgOverride: false, so the metadata door's locked-base refusal is the ruled answer; NAMESPACE_CONFLICT / 422 is N.2's package-registration refusal and would have been wrong here. No new code (the claim's ⛔).
  2. One predicate, one emitter. packagedBaseRefusal runs refusePackagedBaseOverride, guarded by isSealedManagedItem = isArtifactBacked(type, name) and not registryAllowsOverlay(type). For position, isArtifactBacked is lookupArtifactItem, which is registry.getArtifactItem('position', name) (the nested-field and declared-datasource limbs are type-specific and false here), and registryAllowsOverlay('position') is false because OVERLAY_ALLOWED_TYPES is built from DEFAULT_METADATA_TYPE_REGISTRY.allowOrgOverride. The stand-down's own read, packageHoldsPosition, is registry.getArtifactItem('position', name) !== undefined. Same read, same answer; the claim's "⛔ no second predicate" holds, and the code, status and sentence are set in the door's emitter, so this module stamps nothing. isPackagedBaseRefusal hands out only a 403 NOT_OVERRIDABLE or ITEM_LOCKED and re-raises everything else. RIGHT, and the precedent is the /automation doors' refusePackagedFlowBaseChange.
  3. The holder set the read answers. getArtifactItem returns only an item whose _packageId marks a genuine code package (the sys_metadata rehydration sentinel and tenant-authored bodies excluded). Stack-declared positions register under their package since 0b997ea447 (fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 added positions to METADATA_ARRAY_KEYS), and the six built-ins under the security plugin's package since S2 (6051960516). Both kinds are therefore held and refused alike; the carrier note 6040744949 ("stack-declared positions carry no _packageId") is stale, as the dev says. A Setup-authored environment definition is NOT held: a second Setup create of a Setup name in the same organization stays the engine's 409 UNIQUE_VIOLATION, and across organizations under single it stays the same-name rule 6075437096 reserves for S8b. S10 pre-empts neither. RIGHT.
  4. A rename into a held name. Pre-images are read, next() runs, post-images are read back; for every pre.name !== post.name pair whose post.name is held, the refusal is thrown before any saveDefinition, and undoUpdate(targets, patch) restores the patched columns. No definition was saved, so no restoreDefinitionAfterUndo is needed, and the old name's definition is never reached by the delete loop. Before (read on main): the loop stood down for the new name with continue and then deleted the old name's definition, which the changeset's FROM states correctly. RIGHT, and the pin asserts the old definition survives byte-equal.
  5. Order: the engine's own checks keep precedence. The row is written first, as for every S7 write, so a reserved identity name (platform_admin, org_owner, org_admin, org_member) is still refused by the reserved_identity_name CEL validation on sys_position (400 VALIDATION_FAILED, the A tenant can mint a sys_user_position row spelling any built-in identity name — PR #15948 closed every reader, nothing stops the row #15972 invariant) before the middleware's post-write check, and a same-organization duplicate by the unique index (409 UNIQUE_VIOLATION). Only a row the engine accepted is asked about. The two-code outcome for built-in names (identity names 400 by the engine, the audience anchors everyone / guest 403 by the door) is by order, deliberate, stated in the body and pinned by a control. RIGHT: a pre-write refusal would have moved org_admin from 400 to 403, which the claim did not order. The transient row (create, then the undo's delete under the system context) is the loud create-then-delete shape the S7 review accepted; the S7 shape ruling 6075437096 item 1 gives S8 the metadata-first switch that deletes the undo paths, and this diff adds no undo code and reuses undoInsert / undoUpdate.
  6. A verdict the door cannot reach. packagedBaseRefusal re-raises anything that is not its refusal; the try around each refusal loop catches it, undoes the rows (or restores the patched columns) and rethrows, so the failure is the answer and no row is kept. Pinned for the create and the rename with a thrown 503. RIGHT: fails closed.
  7. A bulk create carrying one held name. written holds every row of the write; the first held name throws and undoInsert(written) removes them all, so the write is refused whole. Pinned. updateMany / deleteMany dispatch per id, as S7's review established, so every data-door call site is covered. RIGHT.
  8. Unchanged, each pinned. An edit that keeps a held name (pre.name === post.name: no refusal, then environmentOwns is false and the pair is skipped) and a delete of a held row stand down, which is Q2 = A's actual subject (the re-rule's Q2 was the Setup EDIT of a package-declared row). System writes return before any of this; a walled posture returns at postureEnforcesWall; a doorless kernel returns at isPositionMetadataDoor; a door with saveMetaItem and deleteMetaItem but no packagedBaseRefusal keeps the S7 stand-down for a create, exactly as the /automation doors keep theirs. RIGHT. The S7 test had over-pinned a create under everyone as a pass-on; S10 is the ruled stage for that create, so moving the case into the S10 block as a refusal is right, and the Q2 pin keeps its edit and delete legs.
  9. Walled postures. The write-through stands down under a wall, so group and isolated answer as before (the dev measured 409 UNIQUE_VIOLATION from each organization's seeded rows). The claim excludes the walled half (⛔), and the S7 release 6080018188 carries it to S8 under Q3 = A. RIGHT, and consistent.
  10. The Studio half. PUT /api/v1/meta/position/:name already answered 403 NOT_OVERRIDABLE for a package-held name (refusePackagedBaseOverride, read on main) and for the six built-ins since S2. The S7 release's first step for S10 ("measure first whether feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135's item seam already refuses a Setup position under a package-held name") is answered by the measurement: the item seam guards package registration, the data door never reaches it, and the Studio refusal comes from the package door, not from feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135. No metadata-door change was needed, and none was made (the claim's ⛔). RIGHT.
  11. Public surface. PositionMetadataDoor gains an optional packagedBaseRefusal? member, but the interface lives in position-write-through.ts, which plugin-security's index does not export (its four importers are in-package). security-plugin.ts changes a comment only. No packages/spec, objectql or metadata-protocol change, no new error code, no reader switched, no row deleted, no driver arm touched. Clause-②: no (narrowing) holds: the data door's accept set shrinks (rows 1, 4, 7) and nothing widens.
  12. The pins discriminate by construction. refusalOf resolves null on an accepted write, so envelope(null) can never equal { code: 'NOT_OVERRIDABLE', status: 403 }: the seven S10 refusal pins are red on the main behaviour whatever the ablation's own reading says. The "same verdict a metadata save gets" test compares code, status and message against saveMetaItem's own refusal, an identity check against the door and not a literal message pin, which is inside the census's "no message pin". The engine is real, the SecurityPlugin is real, the door is the real ObjectStackProtocolImplementation. RIGHT.

② Semver level

.changeset/15196-s10-position-namespace.md: @objectstack/plugin-security minor; summary feat(plugin-security)!:; **BREAKING** banner; Clause-②: no (narrowing); ADR-0087 marker not-required (no-migration-prescription) with its why. RIGHT on every axis.

  • Level. The act is an accept-set narrowing at the data door, carried by the banner and the disposition. Under the launch-window convention (check-changeset-no-major's header: a breaking change ships as minor until GA; .changeset/pre.json at mode: pre, tag next, only stands the major guard aside), minor is the convention's level, the grade the S7 (6072809773) and S5a changesets on this card took for the same shape. feat! raises nothing beyond the act. Check Changeset concludes success on the head.
  • Clause-②: line. no (narrowing) in the PR body (line 2), the changeset and the claim 6092128183, the one spelling clause2-line.mjs reserves for a diff that refuses an input the door accepted and widens nothing; yes would be wrong (row 11). One arm, as the closed pair requires.
  • ADR-0087 disposition. No spec key, authorable spelling, export, type or stored shape moves, and the remedy is a data value (the position's name): not-required (no-migration-prescription) is the right category, and the marker closes the other categories on facts.
  • Changeset prose against the diff, sentence by sentence. FROM (a held-name create answered 201 beside the package's row; a rename into one answered 200 and deleted the renamed position's definition) matches the main code read in row 4. TO (403 NOT_OVERRIDABLE relayed as the door built it; no row kept; a renamed row keeps its old name and definition; nothing written to environment metadata for such a name) matches rows 1, 4 and 6. "Unchanged" (engine refusals first; edit and delete as before; system, walled and doorless as before; a walled create still 409) matches rows 5, 8 and 9. The remedy (rename; GET /api/v1/meta/position lists the held names) holds on the door's list once S2 and fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 register both kinds. The showcase readings in FROM are the dev's deleted probe; the committed evidence for the same classes is the unit file against the real door.

③ Boundary flags

Dev report 6092644984: open_questions: []. Its six deviations, judged:

  • The refusal is asked of packagedBaseRefusal, not of packageHoldsPosition. Row 2: the same getArtifactItem read under the door's own guard, with the door's emitter, so no second predicate and no hand-built NOT_OVERRIDABLE; the claim named the read and forbade a second predicate, and this is the shape that keeps the two doors from ever disagreeing. Accepted; it is the better of the two spellings the claim allowed.
  • Row-first order kept. Row 5. Accepted: a pre-write refusal would have changed an engine answer the claim did not order, and S8 owns the metadata-first switch.
  • The Q2 = A pin narrowed to edit and delete; the create case moved to S10 as a refusal. Row 8. Accepted: Q2's subject is the edit, and S10 is the ruled stage for the create.
  • No dogfood pin. The claim's surface is plugin-security and the census's pins are the envelope and the ablation, both delivered in the unit file. Accepted; the HTTP table is testimony, the unit pins and the check-runs are the evidence of record (E2 below).
  • Lock-call verdict formatting; cleanup after the PR. Procedural, accepted.

Its four out-of-scope findings:

  • Class a, the relayed sentence. Verified by reading managedItemSealedSentence: position has no regime row, so a create receives the generic seal sentence ("sealed against in-place edits. Edit the source artifact and redeploy"), whose remedy names an artifact the administrator does not own. The code and status are right and the changeset carries the real remedy, so the verdict does not move (the census pins code and status, no message). Escalated as E1.
  • The walled half answers 409, not the door's 403. Carrier S8 under Q3 = A, as 6080018188 lists. Consistent.
  • Pre-existing same-name rows under single are not swept. Carrier S8b (same-name rule, 6075437096 item 2) and S9 (boot report); C7 owns deletions and the card's ⛔ stop line holds (no seeded row deleted). Consistent.
  • The stale _packageId carrier note. Closed on main by fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262, as the cross-lane note 6057892219 already records; the stale security-catalog.ts module doc that note leaves unedited is already on S8's list (6055398588: a refresh of its measured table). Consistent.

The claim's exclusions (6092128183) hold: plugin-security only; no packages/spec, objectql or metadata-door change; no new error code; not the walled half; no S8 reader switch. The census's S10 pins (envelope code and status, no message pin, the ablation) are delivered. Q4 = A (6050490870) and ADR-0048 addendum N.2 / N.3 are executed for the single data door; #22006 B holds (no junction read moves).

Escalated by this review (neither moves the verdict; each names its carrier):

  • E1 → the owning seat, to file. A user-visible refusal whose prescription names an artifact the caller cannot edit is the trap shape Prime Directive chore: version packages #10 files; the fix is outside this claim's surface, in packages/metadata-protocol/src/packaged-base-regime.ts (a position regime row, or an operation-aware prescription for a create), and belongs to the lane that holds that file. Until then the changeset's remedy is the one an administrator reads.
  • E2 → S9, or the next plugin-security stage that touches dogfood. The Setup data door's S10 answer is pinned at the engine level against the real door; a door-level (POST /api/v1/data/sys_position) pin exists only in the deleted probe. One dogfood row would make the HTTP reading mechanical.

Breaker readings: none. No seeded row is deleted; no driver arm is touched; nothing widens.

Implemented-by: claude/issue-15196-s10-position-namespace
Reviewed-by: session_013j5gkUCpqQiti4GgPqqmnt

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 02:39
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 02:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 99801d8 Oct 10, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15196-s10-position-namespace branch October 10, 2026 03:00
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.

2 participants