Repository navigation
Commit 00bee27
fix(plugin-security)!: a package-declared position is refused at the data door, as the metadata door already refuses it (#22378)
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
#4669's carve-out for packaged permission sets and #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.
- #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](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 9fe7448 commit 00bee27
6 files changed
Lines changed: 746 additions & 18 deletions
File tree
- .changeset
- packages
- plugins/plugin-security/src
- qa/dogfood/test
| 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 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
Lines changed: 113 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
94 | | - | |
95 | | - | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
96 | 97 | | |
97 | 98 | | |
98 | 99 | | |
| |||
297 | 298 | | |
298 | 299 | | |
299 | 300 | | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
0 commit comments