Repository navigation
Commit 99801d8
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)
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](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent b98e984 commit 99801d8
4 files changed
Lines changed: 231 additions & 21 deletions
File tree
- .changeset
- packages/plugins/plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
23 | | - | |
24 | | - | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
25 | 29 | | |
26 | 30 | | |
27 | 31 | | |
| |||
393 | 397 | | |
394 | 398 | | |
395 | 399 | | |
396 | | - | |
| 400 | + | |
397 | 401 | | |
398 | 402 | | |
399 | 403 | | |
400 | 404 | | |
401 | 405 | | |
402 | | - | |
| 406 | + | |
| 407 | + | |
403 | 408 | | |
404 | 409 | | |
405 | 410 | | |
406 | 411 | | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
407 | 415 | | |
408 | 416 | | |
409 | 417 | | |
| |||
418 | 426 | | |
419 | 427 | | |
420 | 428 | | |
421 | | - | |
422 | | - | |
423 | | - | |
424 | 429 | | |
425 | 430 | | |
426 | 431 | | |
| |||
446 | 451 | | |
447 | 452 | | |
448 | 453 | | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
449 | 560 | | |
450 | 561 | | |
451 | 562 | | |
| |||
0 commit comments