Skip to content

fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again - #21857

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21789-lock-reads-row-provenance
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21789-lock-reads-row-provenance

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21789
Clause-②: no

What this changes

The packaged-permission-set lock in plugin-security answers one question for both write doors: is this set shipped by a code (artifact) package? It answered it as "does any engine-registry item of this name carry a package id?". The registry holds stored rows as well as artifacts, and the metadata list read (GET /api/v1/meta/permission, which every Studio page load issues) stamps a stored row's package_id column onto its body as _packageId. So a set saved into a writable runtime package looked code-shipped after the first list read.

The read the console renders from had the matching defect. The security plugin keeps a marked copy of every overlay-backed definition in the metadata manager for the evaluator (the "projection echo"), and the protocol's layered read serves that copy as the item's code layer. The echo carried no provenance, so an org's own set, a clone and a runtime-package set all reported a code layer with no provenance. objectui's permission-matrix editor reads exactly that as "a code package ships this" and rendered them locked, while every write door accepted the save.

Two edits, both in packages/plugins/plugin-security/src:

  1. packaged-permission-set-lock.ts, declaredPackageIdOf: a tenant-authored item (ADR-0010 _provenance: 'org', the stamp the hydrator writes on every stored row) is never a shipped artifact. It is read through isTenantAuthored from @objectstack/metadata-core, the exclusion isCodeArtifactBody and SchemaRegistry.getArtifactItem already apply. No second provenance evaluator. A stored row of a name a code package ships is hydrated wearing the artifact's envelope (_provenance: 'package'), and the artifact itself is in the same list, so a code-shipped set stays locked.
  2. permission-set-projection.ts: the projection echo carries _provenance: 'org' exactly when classifyPackagedPermissionSet (the classifier both write doors ask, fed the same layered probe) answers org for the name. A packaged or unknown verdict leaves the echo unstamped, as before. The reported state and the enforced state are one judgment.

Not touched: metadata-protocol (H4 was not needed), packages/spec, any error code, any export, any parameter of an exported function (the new parameter is on the module-private syncEvaluatorRegistry). The lock-resolution semantics from #21801 are unchanged.

Measurements, before and after

Driven through the real showcase over HTTP, base 088428fb (before) and this branch (after):

shape before: door (PUT /meta after the list read, PATCH /data) before: layered read after: door after: layered read
set in a writable runtime package 403 NOT_OVERRIDABLE / 403 NOT_OVERRIDABLE code = echo, no provenance, editable: true 200 / 200 provenance: 'org', editable: true
org-owned set (data door) 200 / 200 code = echo, no provenance, editable: true 200 / 200 provenance: 'org', editable: true
clone ("Clone to customize") 200 / 200 code = echo, no provenance, editable: true 200 / 200 provenance: 'org', editable: true
control: showcase_contributor (shipped by com.example.showcase) 403 NOT_OVERRIDABLE / 403 NOT_OVERRIDABLE code._packageId = the package, provenance: 'package', editable: false unchanged unchanged

The runtime-package set's registry row after the list read was { _packageId: 'com.dogfood.lock21789', _provenance: 'org' }: the provenance that tells it apart was on the body all along.

Mechanism hypotheses, measured

  • H1, holds. The lock read any non-sentinel _packageId as code-shipped. The three shapes carry, in the registry: runtime-package set { _packageId: PKG, _provenance: 'org' } (the package id appears only after a list read; neither the write-through nor the boot hydration stamps it), org-owned set and clone { _provenance: 'org' }, no package id. The clone's record has created_by and organization_id null, but so do the org-owned set's and the runtime-package set's records: it is not specific to the clone.
  • H2, holds. The platform's one answer is isCodeArtifactBody / isTenantAuthored in @objectstack/metadata-core (already a dependency of plugin-security). The lock reuses isTenantAuthored; it keeps its two documented extensions (the echo-marker skip and the spec packageId fallback).
  • H3, holds, with a refinement. The org-owned set and the clone were never refused by the server (both doors 200 before the fix); their "lock" was report-only. The runtime-package set was refused by both doors while the server's own editable said true. So the reported state and the enforced state were split in both directions, and the fix pins both.
  • H4, not needed. No metadata-protocol edit: the layered read already reads provenance off the code layer, and the echo now states it.
  • H5, the lock's judgment (the smaller one). Stamping the clone's created_by / organization would not change anything the lock or the console reads: the server lock already answered org for the clone, and the console's lock came from the echo's missing provenance.
  • H6, holds. The code-shipped set is still refused at both doors with 403 NOT_OVERRIDABLE (the data door's refusal is the lock's own sentence naming the clone path), and its layered read still reports provenance: 'package', its package id and editable: false, before and after a cold boot.

Pins

  • packaged-permission-set-lock.test.ts, block [#21789]: the classifier over the bodies the hydrator registers (runtime-package row, org-owned row, clone; a shipped artifact, alone and beside a legacy overlay wearing its envelope, in both orders), the layered probe with no registry, the data door (hatch-open double, so only the lock can refuse), and the metadata-door gate, with the refusal asserted on code and status.
  • permission-set-projection.test.ts: the echo of a set no code package ships carries _provenance: 'org'; the control shows the echo of a legacy overlay of a shipped set does not.
  • packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts (new): the showcase, the three shapes made through their real doors (POST /packages then PUT /meta/permission/NAME?package=PKG; POST /data/sys_permission_set; the shipped clone_permission_set action's own payload), the list read, a precondition that the list read stamped the package id, then both doors and the layered read for each shape, the code-shipped control at both doors and on the read, and a cold boot on the same file that reads the three shapes again (the echo minted by the boot's reconciliation) and re-checks the control.

Ablations (each committed first, mutated through scripts/ablation-replace.mjs, rebuilt, dist proven, restored to HEAD)

Both ablations were run at e9dff47f (the fix and its pins committed, pre-merge), each through node scripts/ablation-replace.mjs (anchor hits went from 1 to 0, blob changed), then pnpm turbo run build --filter=@objectstack/plugin-security, then node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER --absent (exit 0: marker absent from every built file), because the dogfood suite resolves plugin-security from dist/. Each restore was proven by blob equality with HEAD and an empty git diff HEAD, then a rebuild and the preflight without --absent (exit 0, marker back in dist/index.js, tree clean).

ablation what was put back unit result dogfood result
1 the lock reads "has a package id" again: if (isTenantAuthored(item)) return null; replaced by a no-op 5 failed / 87 passed: the four classifier/door pins for the runtime-package shape, and the echo pin 2 failed / 12 passed: runtime-package set, metadata door and data door
2 the echo states no provenance again: the _provenance: 'org' spread replaced by an empty one 1 failed / 91 passed: the echo pin 4 failed / 10 passed: the layered-read pin for each of the three shapes, and the cold-boot read

The controls (a code-shipped set refused, and its read reporting provenance: 'package') stayed green in both directions, as they must. A first attempt at ablation 1 used a replacement that left the isTenantAuthored import unused, so the DTS step of the build failed (the JS bundle still carried the ablation and the same pins went red); it was redone with the import kept in use, and the numbers above are from the clean run.

Tests and gates

All on 9e3e32ed (this branch after merging origin/main 8832655a, which carries #21812 and touches plugin-security), after rebuilding the dogfood dependency closure:

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 167 files passed, 3600 tests passed, 45 skipped.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0 (including check:test-typecheck: 0 errors).
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/permission-set-lock-row-provenance.dogfood.test.ts: 14 passed. pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 71 gate commands; all 71 run, every exit 0, --ran verdict: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads first answered exit 3 (PREREQUISITE NOT MET: eight packages outside the dogfood closure had no dist/); those eight were built and the gate re-run, exit 0.
  • Lint, narrowed and proven: pnpm exec eslint --no-inline-config --format json over the five touched TypeScript files (the changeset is not linted): 5 files in the JSON output, 0 errors, 0 warnings. The population is the files this diff touches; eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules, stated in its own header), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes

  • Not in this PR: metadata-protocol. Measured, the layered read did not need to change for the read and the lock to agree: it reads provenance off its code layer, which is the plugin's projection echo, and all three shapes read provenance: 'org' with protocol.ts byte-identical to main. No shape is left unfixed without a protocol edit. This PR's file list is disjoint from fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name (#21803) #21844's (item-lock.ts, protocol.ts, two protocol/objectql tests, the engine-double ledger, its changeset).
  • Observation, not filed (carrier: the domain:engine seat, fix(metadata-protocol)!: an item's lock is the strictest among the installed packages that ship its name (#21803) #21844 holds the region). For a set no artifact ships, the layered read's code layer is still a non-null body (the echo, read through readItemFromMetadataService in getMetaItemLayered), while GetMetaItemLayeredResponseSchema.code says null when no artifact ships the item. The registry fallback right below it already drops a tenant-authored item (runtimeOnly, isTenantAuthored); the MetadataService read does not. After this PR the echo is tenant-stamped, so a provenance-only filter there would answer code: null for these sets; no client reads a wrong answer today, which is why it is noted here rather than built.
  • Finding, reported for the seat to file (same family: a package id read as "shipped by code"). The Discard Overlay action's eligibility (permission-set-overlay-discard.ts, discardPermissionSetOverlay) reads _packageId ?? packageId on the registry item, as the lock did. Measured on 088428fb and again on this branch: after a list read, POST /api/v1/security/permission-sets/ID/discard-overlay on a set saved into a writable runtime package answered 200 and deleted the set's only sys_metadata row. The action declares, and content/docs/permissions/permission-sets.mdx repeats, that it refuses any set that is not currently package-declared. permission-set-drift.ts's declared filter carries the same reading. Not fixed here: outside the claimed file surface.
  • Finding, reported for the seat to file. A data-door edit (PATCH /api/v1/data/sys_permission_set/ID) of a set saved into a writable runtime package writes a second, package-less active sys_metadata row carrying the edit and leaves the package-bound row unchanged (the write-through's update leg calls saveMetaItem without the row's package). Measured on this branch: two active rows after one PATCH; the projected record reads managed_by: 'admin', package_id: null. It is reachable on main before any list read, and through a package-less PUT /meta; this PR lets the data door accept the edit after a list read too.
  • H5. The clone's record has created_by and organization_id null, and so do the other two shapes' records: the projection writes them in system context. It is not what locked the clone, and is not changed here.
  • Docs. No content/docs sentence is made false by this change; content/docs/concepts/metadata-lifecycle.mdx (runtime-created sets, package-bound rows included, keep working) becomes true. content/docs/permissions/permission-sets.mdx still says an edit of a packaged set through Setup becomes an environment overlay, which the lock has refused since the clone-to-customize ruling; that is older drift, not touched here.
  • Report state. Clause-②: no is copied from the claim: the fix restores the lock's declared population (code-shipped sets) and widens no accepted input; a code-shipped set is refused exactly as before.

Generated by Claude Code

claude added 6 commits October 5, 2026 07:04
…nce, not the presence of a package id

The lock's artifact test skips a tenant-authored row (ADR-0010
_provenance 'org', the exclusion isCodeArtifactBody applies), so a set in a
writable runtime package is no longer locked after the list read stamps its
package id. The projection echo states the lock's verdict as its provenance,
so the layered read stops reporting an org's own set as code-shipped.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…table and a code-shipped set as refused

Unit pins over the bodies the hydrator registers (runtime-package row,
org-owned row, clone; a shipped artifact and a legacy overlay of it), at the
classifier, the data door and the metadata-door gate, plus the projection
echo's provenance. A dogfood pin over the showcase drives the three shapes
through both doors and the layered read, across a cold boot.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…oot leg assert the producers it actually has

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…leg reads only what the boot produces

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 5, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 5b2d189e28d5563cbcaa84ac912219017692ea97 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5b2d189e28d5563cbcaa84ac912219017692ea97

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 10:01
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 10:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit c9be1f1 Oct 5, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21789-lock-reads-row-provenance branch October 5, 2026 10:39
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…d decision in words instead of a tracker number (stage 18) (objectstack-ai#21870)

Part of objectstack-ai#20749
Clause-②: no

Stage 18 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the fourth name-ordered file group directly under
`packages/spec/src/data/`: the 17 test files that carry an id from
`filter.test.ts` to `object.test.ts`. They carried 103 messages and 114
tracker ids, citing 81 records. Every one of those ids now either states
what its record decided, in words (form D), or is dropped where the
title already says it. Text only: no assertion, identifier, test count
or code comment changes.

## Census at the base (`c9be1f179d`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the
copies stages 10 to 17 used. A literal counts as a test title when its
folded message is argument 0 of a `describe` / `it` / `test` call,
`.each` / `.skip` / `.only` chains included. Everything else is an
"other" string.

The base is `c9be1f179d`, one commit past the claim's `969ffba25e`. That
commit (objectstack-ai#21857) touches only `plugin-security`, so the test census is
the same. Both instruments read **1045 messages / 1108 ids in 225
files**, the seat's reading and stage 17's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `ui/` | 84 | 396 / 419 | 378 / 401 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `data/` (this PR: the fourth group) | 35 | 185 / 200 | 184 / 199 | 1 /
1 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **225** | **1045 / 1108** | **988 / 1050** | **57 / 58** |

The group reads **103 messages / 114 ids in 17 files**, the seat's
figures, file for file. Every one of them is a test title:

| file (under `data/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `filter.test.ts` | 18 / 19 | 18 / 19 | 0 |
| `form-delete-behavior-options.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `form-return-type-options.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `hook-body.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `hook.test.ts` | 10 / 10 | 10 / 10 | 0 |
| `import-coercion.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `import-mapping-target.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `injected-system-column-provenance.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `injected-system-columns.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `inline-grid-column-currency-scale-refused.test.ts` | 5 / 5 | 5 / 5 |
0 |
| `inline-related-columns.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `managed-api-affordance.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `masked-field-types.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `numeric-column-representation.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `object-image-field.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `object-strictness-batch20.test.ts` | 13 / 17 | 13 / 17 | 0 |
| `object.test.ts` | 36 / 42 | 36 / 42 | 0 |
| **17 files** | **103 / 114** | **103 / 114** | **0** |

Five more test files sit in the same name range and carry no id:
`hook-api`, `hook-body-stored-metadata-target`, `inline-grid-columns`,
`mapping-connector-source` and `mapping`. They are not touched.

- **Controls.** Lit, a title and an "other" string:
`data/query.test.ts`, outside the group, reads 11 / 11 at the head as at
the base, its "other" string at `:202` included. Dark:
`data/import-mapping-target.test.ts` reads 0 / 0 at the head while 2 of
its comment lines still carry a number. Planted in scratch copies of
head files: an id put into a `hook-body.test.ts` title reads 1 / 1, and
an id put into a `masked-field-types.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals as the
gate pattern in 16 of the 17 files at the base. `object.test.ts` reads
one more, at `:857`, which is the colour value `'#00FF00'` and not a
tracker id. At the head the wider pattern reads 0 in 16 files and that
same colour in `object.test.ts`.
- **At the head:** 942 messages / 994 ids in 208 files. The 17 files
read 0 / 0, and no other file moved.

## How the area was chosen

`data/` has no subdirectory to split by, so its stages take name-ordered
file groups near the ~100-id bound. Stage 17's re-cut named this group
at 114, with `object.test.ts` alone carrying 42, and this census reads
114, so the rule needed no re-cut.

**Named for the next stages** (re-cut from the head census, 942 / 994;
`data/` 82 / 86 left, in 18 files):
- `data/`, one more stage: `query-transport.test.ts` to
`validation.test.ts` (11 files, 34 / 34) with `data/driver/` (7 files,
48 / 52): 82 messages / 86 ids.
- `ui/` 419, about four stages. `api/` 201, two. `system/` 165, two. The
files directly in `src/`, 120, one.
- The three docblock needles (`ai/build-progress.test.ts:236`, `:237`,
`contracts/approval-service.test.ts:274`), one stage with their
docblocks.

## What each id became

34 literals (41 ids) now state a decision in words. 69 literals (73 ids)
drop a number the title already explains.

Every cited record was read with its comments through REST. 73 answer
200; `framework#2536` is counted there, because the repository was
renamed `framework` → `objectstack` (`apps/docs/lib/layout.shared.tsx`
records the rename). The old name answers 403 from this session, and the
same number under the current name is the compactLayout retirement that
the title names. Eight answer 404, and each decision was read from what
landed, by REST GET of the landing commit and its CHANGELOG entry: objectstack-ai#6571
(`2f3e79351e`), objectstack-ai#10165 (`8012960508`), objectstack-ai#10347 (`530c1df653`), objectstack-ai#10527
(`5649efbf93`), objectstack-ai#11195 (`b372318836`), objectstack-ai#11408 (`f11fc61c51`), objectstack-ai#13644
(`34ce8e7dbe`) and objectstack-ai#18012 (`176b03582e`).

| record(s) | literal (under `data/`) | now reads |
|:--|:--|:--|
| objectstack-ai#5685 | `filter.test.ts:77` | "string comparands — the ordering slots
take a string, which is what the date macros emit". `string` joined the
four ordering slots because the platform's own date-macro resolver
produces only strings. |
| objectstack-ai#14080 | `filter.test.ts:166` | "null comparand — refused in the four
ordering slots, like a null list member". Ruled A: refused at the same
entrance, in the same shape, as the null `$in` member. |
| objectstack-ai#6571 (404) | `filter.test.ts:364` | "string endpoints — `$between`
takes the ISO and clock strings the platform produces". The sibling half
of objectstack-ai#5685, from `2f3e79351e`. |
| objectstack-ai#7711 (2) | `filter.test.ts:549` | "the whole-filter face refuses
these field conditions too — green while the group branch was a
non-strict catch-all". The group branch became `.strict()`, so a refused
member has nowhere to land. |
| objectstack-ai#5222 | `filter.test.ts:576` | "leaves the four ORDERING slots taking
a reference — a column-to-column comparison, and the prescribed
alternative". `$field` compiles to a same-table column comparison on SQL
push-down. |
| objectstack-ai#5322 | `filter.test.ts:1541` | "leaves the empty-combinator
identities accepted — each reduces to its boolean unit". Ruled:
`{$and:[]}` is every row, `{$or:[]}` none, `{$not:{}}` none. |
| objectstack-ai#14104 | `filter.test.ts:1851` | "FieldReferenceSchema.addDays — a
whole-day offset on a reference, an integer or another column". Ruled A:
an offset on the field reference. |
| objectstack-ai#16923 | `filter.test.ts:1998` | "filter.zod.ts docblock @examples — a
`$field` comparand names a column of the same row". The relation-path
example was the wrong half; the same-table prose was right. |
| objectstack-ai#14010 | `hook.test.ts:352` | "runAs — a hook may run as `system` or
`user`, and inherits by default". Ruled: `runAs: 'system' \| 'user' \|
'inherit'`, default `'inherit'`. |
| objectstack-ai#13644 (404) | `hook.test.ts:529` | "Referential-Cleanup Marker —
declared, and true only on a reference-cleanup write by the engine".
Adopted by maintainer ruling, from `34ce8e7dbe`. |
| objectstack-ai#4269 | `hook.test.ts:924` | "defineHook — the authoring factory, so a
hook is validated where it is written". The `defineDatasource` pattern:
the convention-scan path got a parse at authoring time. |
| objectstack-ai#3493 | `hook.test.ts:1148` | "PRESERVES `preserveAudit` through a
parse (the opt-in a historical import uses to keep its audit stamps)". |
| objectstack-ai#5945 | `hook.test.ts:1286` | "HookContext.api typing — the minimum
scoped context the docs teach: `object()` and `transaction()`". Ruled
option C. |
| objectstack-ai#4173 (2) | `import-coercion.test.ts:23`, `:48` | "import boolean
tokens — one table for the server coercion and the Import Wizard
preview" and "import reference types — exported from spec, not copied at
each consumer". |
| objectstack-ai#8116 | `injected-system-column-provenance.test.ts:53` | "provenance
derivation at its spec home — where the author-time linter can reach
it". Ruled option 1: the derivation moved into the contract package,
which the linter may import. |
| objectstack-ai#5378 | `injected-system-columns.test.ts:17` |
"resolveInjectedSystemColumns — the injected columns, so author-time
validation resolves them too". |
| objectstack-ai#20045 | `inline-grid-column-currency-scale-refused.test.ts:172` |
"SHAPE PARITY with the currency FIELD refusal (its ruled text carried,
not reworded)". "ruling B" is the ruling that retired `scale` on a
currency field; the letter went with the id. |
| objectstack-ai#7521 | `managed-api-affordance.test.ts:57` | "is the exact shape the
boot only warned about, now named at authoring time (sys_environment /
sys_package)". Ruled: an authoring-time check, with boot left at warn
and strip. |
| objectstack-ai#16318 | `numeric-column-representation.test.ts:24` | "the numeric
physical-representation table — one table the driver and the migration
generators both read". Ruled C: one table in `packages/spec`, both
producers read it. |
| objectstack-ai#4001 (2) | `object-strictness-batch20.test.ts:93`, `:229` | "批 20,
unknown keys refused — …", as stage 15 wrote "batch D, unknown keys
refused". `:132` already says it and keeps only "批 20". |
| objectstack-ai#1535, objectstack-ai#4519, objectstack-ai#4522 | `object-strictness-batch20.test.ts:124` | "the
root itself was already closed, on parse as well as create() — this
batch is the level BELOW it". |
| objectstack-ai#5014 | `object-strictness-batch20.test.ts:187` | "⚠️ `systemFields`
is the batch's ONE union flattened to a bare `Invalid input` — …". |
| objectstack-ai#5677, objectstack-ai#6365 | `object-strictness-batch20.test.ts:380` | "… — since
the unit anchor is injected, the property governs
`owning_business_unit_id` too". objectstack-ai#6365 is dropped: the title already
states its correction. |
| objectstack-ai#11195 (404) | `object-strictness-batch20.test.ts:422` | "the two
`userActions` vocabularies stay disjoint, the three adopted view keys
included — …". `b372318836` adopted `group` / `hideFields` / `rowColor`
onto the view block. |
| objectstack-ai#4001, objectui#4772 | `object-strictness-batch20.test.ts:528` | "批 20
— `IndexSchema` is closed (the held 14th site, once the console index
editor converged on it)". |
| objectstack-ai#10527 (404) | `object.test.ts:188` | "retention + ttl + archive
triple — refused unless the ttl restates the age bound". From
`5649efbf93`. |
| objectstack-ai#10347 (404) | `object.test.ts:192` | "still accepts the ttl + archive
pair, whose ttl cutoff picks the rows to archive (no retention)". From
`530c1df653`. |
| objectstack-ai#2834 | `object.test.ts:264` | "accepts retention.onlyWhen with scalar
and $in predicates (mixed tables, where only terminal rows age out)". |
| objectstack-ai#10165 (404) | `object.test.ts:289` | "accepts ttl.onlyWhen with the
canonical null predicate — so rows whose value is absent are spared".
From `8012960508`. |
| objectstack-ai#3175 | `object.test.ts:1101` | "ownership record-model field —
declared, so the opt-out the registry reads can be authored". |
| objectstack-ai#11408 (404), objectstack-ai#10144 | `object.test.ts:1713` | "ObjectSchema editMode
(declared by maintainer ruling: the renderer reads it, so the spec
declares it)". From `f11fc61c51`, in objectstack-ai#10144's declare-or-rule-out
family. |
| objectstack-ai#14935, objectstack-ai#14637 | `object.test.ts:2135` | "isPublicSharingEnabled — the
one canonical standing share-link policy predicate". The runtime mirror
was retired. |

**Dropped where already stated** (69 literals, 73 ids). A number goes
only where the title already says its decision, for example "$field
members are refused (objectstack-ai#7596)", the four `crypto.hash` titles in
`hook-body.test.ts` (objectstack-ai#4391), the `objectstack-ai#20045 —` prefix on the four other
describes of the currency-scale file, and twenty-nine `object.test.ts`
titles such as "managedBy: retiring the overloaded `system` bucket
(objectstack-ai#3355)". `(ADR-0049)`, `(ADR-0066)`, `(ADR-0100)` and `(ADR-0087 …)`
stay: they cite decision records by number, not tracker ids.

**Three judgements, each declared:**
- `filter.test.ts:298` keeps "ruled 2026-08-31" and drops only
`(objectstack-ai#13357)`. The sibling title at `:656` names "the 2026-08-31 ruling",
so the date stays as its anchor.
- The `批 20` label stays on the four batch-20 describes, because the
file's own name and header carry it. Only `objectstack-ai#4001` and `objectui#4772`
went.
- `object-strictness-batch20.test.ts:563` drops "(objectstack-ai#5114 class)": the
title already says what is pinned, that the tombstones keep their
prescription through the strict close.

## Readers

- **Test-name filters:** none. No tracked script, workflow or config
passes `-t` / `--testNamePattern`.
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`.
- **Projects:** none of the 17 files is listed in
`packages/spec/vitest.repo-tests.json`; all 17 run in the `local`
project.
- **By substring:** every old literal, plus a window around each id (311
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 8 needle hits fall on 7 lines:
  - a code comment in `object.zod.ts:2532`;
  - two release-owned CHANGELOG lines;
- a sibling title in this card's `system/` stage
(`system/job.test.ts:471`);
- sibling titles in `cli` (`extract-hook-body.test.ts:179`),
`driver-sql` (`sql-driver-16318-numeric-representation.test.ts:64`) and
`objectql` (`engine.test.ts:831`).
  None reads a spec test title.
- **The files by name:** 96 references to these file names outside
CHANGELOGs. The gate ledgers among them (`test-typecheck-debt.json`,
`engine-double-contract.pinned.json`,
`objectql-double-limit.baseline.json`) key on the file and on error
signatures, not on a title, and each gate exits 0 at the head.
`scripts/check-org-identifier.mjs` counts `session: { … tenantId … }`
literals in `hook.test.ts`, which this PR does not touch. ADR-0129
quotes "name-as-identity", a title this PR does not change.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. This stage declares no line: every
changed leaf is a title.

- **Result:** 17 of 17 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 103 changed string leaves in 103 literals, all titles. The
diff's `+` and `-` lines are exactly the 103 planned lines, and every
file keeps its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once in the reported run):** identifier rename DIFF; numeric literal
DIFF; comment edit COMMENT DIFF; a non-title string given an id
VIOLATION; a rewritten title given a new id VIOLATION; a title that was
id-free at base edited VIOLATION; one title reverted to base SAME; an
`it.each` row name given an id VIOLATION; an undeclared `expect` message
changed VIOLATION; a title re-split into a `+` chain DIFF. The non-title
control's first anchor matched nothing (0 hits, so nothing ran). Its
anchor was corrected and all ten were run again.

**Test counts:** the 17 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 670 tests, all passed, with the same count and status
sequence per file in 17 of 17. 378 full test names change, and each
equals the base name with the planned replacements applied (0
mismatches). No full name repeats on either side.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was two commits
past the base (`9f9510f25e`: objectstack-ai#21858, objectstack-ai#21862). Neither touches any of the
17 files; the one `packages/spec` path among them is
`api/error-code-ledger.zod.ts`. So `main` was not merged. `git
merge-tree` onto `9f9510f25e` is clean. No open PR touches the 17 files.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
17 touched files are in it, and no `*.test.ts` at all. The controls
`src/data/filter.zod.ts` and `dist/index.mjs` are in it.
- In the built `dist/`, a new phrase and an old literal each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `21f39afe57`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18387 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 17 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
  - `check:generated`: all 15 generated artifacts up to date.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stages 13 to 17, and all 79 exit 0. `--ran` reconciles: 79
derived, 79 run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit
code recorded.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- The derivation printed `STALE TREE`:
`scripts/engine-double-contract.pinned.json` moved on `main` after the
base. This diff adds and changes no engine double,
`check:engine-double-contract` exits 0 on this tree, and the queue
re-runs it on the merged generation.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 17 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 17 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 206 changed lines.

## Acceptance notes

- **No needle in this group.** Every id was a test title; no expected
value of an assertion over a source docblock was found. The three known
needles are untouched.
- **Same-id test titles in this card's later stages** go with those
stages: 38 lines in `packages/spec/src`, 9 in `system/` (for example
`system/job.test.ts:471`, the `job.timeout` twin of `hook.test.ts:1382`)
and 29 in `ui/` (for example the `objectstack-ai#4001 批 15` / `批 16` / `批 18` / `批 19`
describes and `ui/view-filter-rule-wire-id.test.ts`'s four `objectstack-ai#5114`
describes).
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec/src` finds 140 lines citing ids this PR handled:
`objectql` 23 (13 files), `service-analytics` 18 (8), `lint` 17 (9),
`driver-sql` 11 (7), `cli` 10 (4), `runtime` 10 (7), `platform-objects`
5, `plugin-audit` 5, `plugin-security` 5, and fewer in 16 more places,
among them two `packages/spec/scripts/*.test.ts` titles (outside `src/`)
and one title each in `examples/app-crm` and `examples/app-showcase`.
- **Code comments still carry ids** in these files and their sources,
for example the `// [objectstack-ai#20150]` block at
`import-mapping-target.test.ts:17`, the `objectstack-ai#4001 批 20` header of
`object-strictness-batch20.test.ts` and `object.zod.ts:2532`. Comments
are not this card's share, and none is touched here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… code package ships, so a runtime-package set's only stored row is no longer deleted (objectstack-ai#21873)

Fixes objectstack-ai#21860
Clause-②: no

## What this changes

The Discard Overlay action (`POST
/api/v1/security/permission-sets/:id/discard-overlay`) is declared to
refuse any permission set that is not package-declared, so that it can
never destroy a set the environment authored (its docblock, and the
permission-sets docs page). It decided "package-declared" by asking
whether any engine-registry item of the set's name carried a package id
(`_packageId ?? packageId`). The registry holds stored rows as well as
artifacts, and the metadata list read (`GET /api/v1/meta/permission`,
issued by every Studio page load) stamps a stored row's `package_id`
onto its body as `_packageId`. So a set saved into a writable runtime
package passed the check after the first list read: the action answered
`200` and deleted the set's only `sys_metadata` row. The drift
diagnostics (`computePermissionSetDriftDiagnostics`) read the package id
the same way, and their `overlay_shadow` detail names Discard Overlay as
the remedy.

Two edits, both in `packages/plugins/plugin-security/src`:

1. `permission-set-overlay-discard.ts`: eligibility is
`classifyPackagedPermissionSet`'s verdict for the set's name. That is
the classifier the lock's two write doors and the overlay detection
reading already ask, over the same engine registry. Only `packaged`
proceeds. `org` and `unknown` are both refused with the action's
existing refusal (`PermissionDeniedError`, `403 PERMISSION_DENIED`),
because the safe direction for a destructive action is the reading's: a
set this environment cannot prove a code package ships is never deleted.
The refusal's sentence now says the set is not shipped by any installed
code package (for `unknown`: that this could not be determined, with the
classifier's reason).
2. `permission-set-drift.ts`: the declared population is gated on the
same verdict, per name.

No second evaluator. In both files the per-item test that remains below
the verdict (`_packageId ?? packageId`) no longer decides anything: it
only picks, among the registry items of a name the classifier has
already called code-shipped, which body is used (the degraded-kernel
resync body in discard, the compared bodies in drift), by the same
selector as before. So for every code-shipped set the accepted
population, the bodies and the audited or reported package are what they
were.

This builds on PR objectstack-ai#21857 (landed as c9be1f1, merged into this branch):
there the classifier learned to skip a tenant-authored registry item
(`_provenance: 'org'`), so a runtime-package set's stored row reads
`org`. It already excluded the `'sys_metadata'` runtime-shadow sentinel.

Not touched: `packaged-permission-set-lock.ts`,
`permission-set-projection.ts`, `packages/spec`, any error code, route,
export or exported signature.

Docs: the permission-sets page's Discard Overlay sentence now names the
sets the action refuses (a set created in the environment, a clone, a
set saved into a writable runtime package). "Package-declared" on its
own reads as covering a runtime package's set.

## Measurements, at the door (showcase, booted through
`@objectstack/verify`, two boots on one database file)

| shape | old reading (ablation A below, and this branch before objectstack-ai#21857
landed) | this PR (62143b0) |
|---|---|---|
| set saved into a writable runtime package, after the list read |
`200`, `overlaysDiscarded: 1`: its only `sys_metadata` row deleted |
`403 PERMISSION_DENIED`, every row intact |
| org-owned set (data door) | `403 PERMISSION_DENIED` (no package id on
its item) | `403 PERMISSION_DENIED`, every row intact |
| clone ("Clone to customize") | `403 PERMISSION_DENIED` (no package id
on its item) | `403 PERMISSION_DENIED`, every row intact |
| control: `showcase_contributor` (shipped by `com.example.showcase`)
with a legacy overlay | `200`, overlay discarded, record healed to the
artifact | unchanged |

The runtime-package set's registry row after the list read is `{
_packageId: 'com.dogfood.discard21860', _provenance: 'org' }` (asserted
as a precondition before any refusal is read). The control's
precondition: after the cold boot, its record enforces the legacy
overlay's grants and the boot's drift pass wrote `drift_status:
'overlay_shadow'`.

## Mechanism hypotheses, measured

- **H1 holds.** Reproduced at both levels before the fix took effect:
the unit pin read "promise resolved instead of rejecting", and the door
answered `200` with `overlaysDiscarded: 1` (the set's only stored
definition deleted).
- **H2 holds, with one refinement.** On `main` at dispatch (`5b2d189e`)
the classifier alone did NOT exclude the runtime-package set: it read
any non-sentinel package id. That is exactly what PR objectstack-ai#21857 changed in
the lock (`isTenantAuthored`), and why this PR waited for it and is
built on it. Measured on this branch before that merge: the three
runtime-package legs (two unit, one door) stayed red with the classifier
swap alone, and everything else was green. Only the `packaged` verdict
may discard. `unknown` is refused, mirroring the overlay detection
reading's direction rather than the write door's.
- **H3 holds.** The drift filter had the same reading. It is gated on
the same verdict, and a pin checks that the report and the action agree,
shape by shape: reported exactly when eligible.
- **H4 holds.** Every existing pin in both test files is unedited and
green. The door control still discards a shipped set's overlay and heals
its record.

## Pins

- `permission-set-overlay-discard.test.ts`, new block: each
environment-authored registry body is refused with `code`
`PERMISSION_DENIED` and `status` `403`, and both tables are counted and
compared whole before and after. The bodies are the runtime-package row
(`_packageId` plus `_provenance: 'org'`), the org-owned set, the clone,
and the lock's documented `'sys_metadata'` runtime shadow. A
code-shipped set sits beside them in the registry. Further cases: a
registry read that throws (`unknown`) is refused with the rows intact,
and a control discards a code-shipped set's overlay beside a hydrated
overlay item that wears the artifact's envelope.
- `permission-set-drift.test.ts`, new block: a drifted
environment-authored set with a stored definition is never diagnosed
(for each of the four shapes). The agreement pin runs the drift report
and Discard Overlay over one registry: the code-shipped set is reported
and eligible (`409 INVALID_STATE`, nothing to discard), each other shape
is unreported and refused (`403 PERMISSION_DENIED`), and no stored
definition is deleted.
-
`packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts`
(new): the three shapes made through their real doors (`POST /packages`
then `PUT /meta/permission/NAME?package=PKG`; `POST
/data/sys_permission_set`; the shipped `clone_permission_set` action's
own payload). Then a legacy overlay row for the shipped control, a cold
boot, and the list read. Then a precondition for the stamp, then each
shape's refusal with its record ids and stored rows compared before and
after. The drift report run over the booted registry judges the shipped
set and none of the three shapes. The control's discard returns `200`,
its overlay row is gone, its record is kept, and its grants equal the
shipped artifact's again.

## Ablations

The fix and its pins were committed first. Each ablation went through
`node scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed), then
`pnpm --filter @objectstack/plugin-security build` and `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER`
(exit 0, marker in `dist/index.js` and `dist/index.mjs`). The dogfood
suite resolves `plugin-security` from `dist/`. Each restore was proven:
blob equal to `HEAD`, `git diff HEAD` empty, a rebuild, `--absent`
preflight exit 0, and an empty `git status --porcelain`.

They ran before objectstack-ai#21857 landed, on a local, never-pushed composition:
this branch at a4d82d3 plus objectstack-ai#21857's two source files, blob-identical
to 9e3e32e. The plugin-security source in that composition is
byte-identical to this PR's head. Only objectstack-ai#21857's two test files differ,
and they were not in the composition.

| ablation | what was put back | unit result | dogfood result |
|---|---|---|---|
| A | discard's eligibility reads "an item of this name carries a
package id" again (the verdict rebuilt as: some `readDeclared` item has
the row's name and a truthy `_packageId ?? packageId`) | 3 failed / 29
passed: runtime-package and sentinel refusals, and the agreement pin | 1
failed / 6 passed: runtime-package set, `200` with `overlaysDiscarded:
1` |
| B | drift's population reads the package id again (the classifier gate
made always-true) | 3 failed / 29 passed: runtime-package and sentinel
population pins, and the agreement pin | 1 failed / 6 passed: the
drift-population leg |

The org-owned, clone and control legs stayed green under both, as they
must: the old reading already refused the first two and accepted the
third. A first attempt at ablation A used a typed replacement that
narrowed the verdict's type, so the DTS step failed. The JS bundle still
carried the marker and the same three unit pins and one door pin went
red. It was redone with a cast that keeps the declared type, and the
numbers above are from the clean run.

## Tests and gates (head 62143b0 unless noted)

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 167 files, 3611 passed, 45 skipped (on 50f93c1, the
merge of `origin/main` at c9be1f1; the only later commit is a comment
in the dogfood file).
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0,
including `check:test-typecheck` (0 errors). `pnpm --filter
@objectstack/dogfood typecheck`: exit 0 (its `tsconfig.json` includes
`test/**`).
- `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/permission-set-discard-overlay-eligibility.dogfood.test.ts`: 7
passed on 62143b0. Together with objectstack-ai#21857's
`permission-set-lock-row-provenance.dogfood.test.ts`: 21 passed on
50f93c1.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 97 commands on 62143b0; all 97 ran,
every exit 0. `--ran`: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN. The
first battery, on 50f93c1, found one real red:
`check:cross-package-test-inputs` read a docs path spelled in the
dogfood file's header comment as a test input. The comment now names the
page in words (62143b0). It also hit two PREREQUISITE NOT MET (exit 3:
`check:skill-examples`, `check:dual-build-cjs-loads`), cleared by
building the eight packages they named before the second battery. The
derivation printed a stale-tree note: `origin/main` moved two commits
(objectstack-ai#21858, objectstack-ai#21862) after the merge, and one of them edits
`scripts/engine-double-contract.pinned.json`. Those commits touch
neither this diff's packages nor its files, and `git merge-tree` against
them is clean.
- Lint, narrowed and proven: `pnpm exec eslint --no-inline-config
--format json` over the five touched TypeScript files gives 5 files in
the JSON output, 0 errors, 0 warnings, on 62143b0. The population is
the files this diff touches. The `.md` and `.mdx` files are outside the
lint globs in `eslint.config.mjs`, which never enables type-aware
linting (no `parserOptions.project`, no typed rules, stated in its own
comment), so this diff cannot move the verdict on any untouched file.
The repo-wide `pnpm lint` is CI's.

## Acceptance notes

- **Route.** The suggested route was to replace both `_packageId ??
packageId` readings with the verdict. Measured, the eligibility half is
replaced. The per-item selector is kept below the verdict, because the
two alternatives both change something for code-shipped sets. Keying it
on the verdict's package id changes which bodies are compared when two
code packages ship one name (ADR-0048 §3.4 lets them coexist under
distinct registry keys). Dropping it would let a projection echo with no
package id into the drift comparison.
- **The `'sys_metadata'` sentinel shape** is pinned at the unit level
only. It is the runtime shadow the lock module's docs describe; no door
producing it was measured here. The old reading accepted it, and the
classifier already refused it on `main`.
- **Drift's reach.** The boot's drift pass runs before any list read,
and the boot's hydration does not stamp the package id (measured by
objectstack-ai#21857). So on the showcase the drift misjudgment shows only in a later
run of the diagnostics after a list read. The door pin runs
`computePermissionSetDriftDiagnostics` over the booted stack's registry
after the list read.
- **Observation, not filed (carrier: none).** In the degraded-kernel
branch (no metadata protocol), discard's resync body is the first
registry item of the name that carries any package id. If a hydrated
overlay of a code-shipped name preceded its artifact in registry order,
that body would be the overlay's. Nothing measured shows that order. It
is unchanged here, by H4.
- **Changeset.** `.changeset/21860-discard-overlay-eligibility.md`,
`patch` for `@objectstack/plugin-security`, with `Clause-②: no` copied
from the claim. The action's declared population (sets a code package
ships) is restored, and no accepted input widens.

---
_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… a writable runtime package updates its own row instead of forking it (objectstack-ai#21881)

Fixes objectstack-ai#21861
Clause-②: no

## What this changes

Setup saves a permission set through the data door (`PATCH
/api/v1/data/sys_permission_set/:id`). The security plugin's
write-through (`createPermissionSetWriteThrough`,
`packages/plugins/plugin-security/src/permission-set-projection.ts`)
redirects that edit into the metadata store. Its update leg merged the
patch into the stored body and called `saveMetaItem` with no
`packageId`.

A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a
save that names no package targets the package-less row
(`SysMetadataRepository.put`: an omitted `packageId` is the unbound
row). So for a set saved into a writable runtime package (`PUT
/api/v1/meta/permission/:name?package=PKG`), whose only stored row is
bound to that package, the edit answered `200` and minted a second,
package-less active row carrying the edit. The package-bound row stayed
as it was.

The update leg now reads the package binding of the row it edits and
passes it as `packageId`:

- The row is the `overlay` layer of the layered envelope the leg already
holds. `effectiveBodyForRow` takes that layer as the base the patch
merges into.
- Its binding is read from the metadata door's own single-item read,
`protocol.getMetaItem({ type: 'permission', name })`. That read serves
the row through the same served-row resolution as the layered read
(`findServedOverlayRow`, no package), and it states the row's
`package_id` on the item as `_packageId` ("surface the persisted
software-package binding", `protocol.ts`). The binding is not taken from
the patch, from the projected record, or from a registry item.
- No `overlay` layer means no stored row. Then there is nothing to fork,
and the call is unchanged.
- A package-less row carries no `_packageId`, so its save still names no
package.
- A code-shipped set is refused by the lock before this read
(`assertPermissionSetNotPackageDeclared` runs in the pre-pass). The lock
and its refusal do not move.
- A failed binding read is not caught. Guessing the binding would choose
which row the save lands in.

One file of production code, one new non-exported helper. No
`packages/spec` or `packages/metadata-protocol` edit, no new error code,
no new exported symbol, and no second classifier: every target reaching
the save has already been classified `org` by
`classifyPackagedPermissionSet`, so the binding read only tells a
package-bound `org` set from a package-less one.

## The dispatch hypotheses, measured

- **H1 (the update leg saves package-less and forks): confirmed.** On
`origin/main` `88a39c09` (this branch's base, which carries PR objectstack-ai#21857),
through the new door pin's own steps, the runtime-package set's first
data-door edit answered `200`. Its active rows went from one, bound to
the package, to two: `{ package_id: null, description: "Edited at the
data door" }` and `{ package_id: "com.dogfood.bind21861", description:
undefined }`. The update leg is the `saveMetaItem` call at `:1374` on
this base. The `:1297` named in the dispatch is the insert leg's call.
- **H2 (where the binding can be read): measured before any edit.** The
projected `sys_permission_set` record reads `managed_by: "admin"`,
`package_id: null`, so the projection does not carry the binding. The
layered read's `overlay` layer has no `_packageId`, and its envelope
`packageId` is null: the code layer is the projection echo.
`getMetaItem`'s `item._packageId` is `com.dogfood.bind21861`, both
through `GET /api/v1/meta/permission/:name` and through
`protocol.getMetaItem` in-process. That existing reader is the one used
here.
- **H3 (only the runtime-package set needs the binding): held.** It is
pinned at the door: the package-less set saves package-less, and the
code-shipped `showcase_contributor` is still refused with `403
NOT_OVERRIDABLE` with its row count unchanged.
- **H4 (other legs): none forks the same way, so none is changed.**
Details are under Acceptance notes.

## Pins

- **Door pin** (new file
`packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts`,
showcase). Every case counts the active `sys_metadata` rows for the name
under both type spellings and in every scope, before and after the edit:
- a runtime-package set: `200`, exactly one row before and after, still
bound to the package and carrying the edit. Edited twice, before and
after the list read (`GET /meta/permission`) that every Studio page load
issues;
- a package-less set: `200`, one package-less row before and after,
carrying the edit;
- a code-shipped set: `403` with `code: NOT_OVERRIDABLE`, and the stored
rows are unchanged.
- **Unit** (`permission-set-projection.test.ts`). The suite's existing
protocol double now keys a stored row by its package, as the repository
does, and serves the row's binding on `getMetaItem`. No new engine
double, so the engine-double ledger does not move. Four cases:
package-bound, package-less, a filtered edit spanning one of each (each
saved into its own row), and the code-shipped refusal (`code` and
`status` asserted, no save, the binding never read). Each case asserts
the row count before and after.

## Ablation (committed fix first, then mutated, then restored)

At `32048afa30`, `scripts/ablation-replace.mjs` replaced the anchor
`item: body, ...packageArg, ...actorArg });` (1 hit, then 0) with `item:
body, ...actorArg });` (0 hits, then 1). This puts the package-less save
back. Blob `e77bd871` became `4186f983`. The run was wrapped in a `trap`
restore on EXIT, INT and TERM.

- Rebuild: `pnpm turbo run build --filter=@objectstack/plugin-security`
exited 1, at DTS only (`TS6133: 'packageArg' is declared but its value
is never read`). The JS was already emitted, and `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security "item:
body, ...actorArg }"` found the marker in 2 built files
(`dist/index.js`, `dist/index.mjs`). The ablation was live in the
artifact the door pin consumes.
- Unit: 2 failed, 72 passed. The first red is the row count: `expected [
…(2) ]`, the package-bound row plus `{ package_id: null, description:
"edited at the data door" }`.
- Door pin: 2 failed, 2 passed. The runtime-package set's two cases went
red with two active rows, `{ package_id: "com.dogfood.bind21861",
description: undefined }` and `{ package_id: null, description: "Edited
at the data door" }`. The package-less and code-shipped cases stayed
green, as they should.
- Restore: blob equal to HEAD (`e77bd871`), `git diff HEAD` empty, `git
status --porcelain` empty across the whole tree. After the rebuild,
`--absent` found the marker in 0 of 6 built files. Unit 74 of 74 passed,
door pin 4 of 4 passed.

## Tests (all at `32048afa30`)

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 167 files, 3604 passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-security run typecheck`, which
includes `check:test-typecheck` over `tsconfig.test.json`: pass. `pnpm
--filter @objectstack/dogfood run typecheck`: pass.
- After a fresh closure rebuild (`turbo run build
--filter=@objectstack/dogfood^...`, 63 tasks): dist preflight shows the
fix's spelling present and the ablation marker absent.
`permission-set-projection.test.ts` 74 of 74 passed. The door pins
`permission-set-write-through-package-binding` and
`permission-set-lock-row-provenance` (PR objectstack-ai#21857's) passed 18 of 18.
- Narrowed lint: `eslint --no-inline-config --format json` over the
three changed `.ts` files reported 3 files, 0 errors, 0 warnings. The
population is the config's `**/*.{ts,…}` globs, and the fourth path is
the `.md` changeset. The config never enables type-aware linting (no
`parserOptions.project`), so the diff cannot move a verdict on an
untouched file. The repo-wide `pnpm lint` is left to CI.
- `integration`-tier tests are not relevant (no `packages/cli` path).

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 70 commands at `32048afa30`. All 70
were run, and each exit code was recorded. 69 exited 0 on the first
pass. `check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`: 8
packages outside the dogfood closure had no `dist/`). Those packages
were built (all cache hits), and the gate exited 0 on the rerun. `--ran`
reports: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. The derivation
names 8 commands the dispatch-time list did not have, all
changeset-related because this PR adds a changeset:
`check-adr-0087-registration` (both), `check-empty-changeset` (both),
`release-rehearsal-clone --self-test`, `release-pending-publish
--self-test`, `check:objectui-changeset` and
`check:pm-changeset-deadline-census`.

## Acceptance notes

- **The write-through's other legs (H4), read rather than changed.**
- **Insert:** the duplicate-name check refuses whenever the name has a
record. A stored row with no record is only transient, because the
projector is awaited on every save and boot reconciliation creates a
record for every overlay. No door was measured to fork through it.
- **Restore:** it re-authors package-less after a delete. A
runtime-package set's delete removes its bound row, because
`deleteMetaItem` with no package matches the row in any package. So a
restore would leave one package-less row: a lost binding, not two rows.
That needs a different fix, since nothing holds the binding once the row
is gone. The leg is also unreachable on a real engine, because no trash
state exists (the leg's own comment). Carrier: none.
- **Reconcile backfill:** only names with no metadata presence, so there
is nothing to fork.
  - **Delete:** matches any package, so no fork.
- **Degraded path, unchanged.** When the layered pre-read fails and the
lock answers from the engine registry alone, the leg has no envelope. It
reads no binding and saves package-less, as before. That needs a
transient read failure followed by a successful save. Carrier: none.
- **A stored row bound to a read-only package (a booted code package, or
a `system`/`cloud`-scoped one) with no artifact.** For such a row, the
save would now meet the protocol's own read-only-base refusal, which
`PUT /meta?package=` meets too, instead of forking silently. No door
mints such a row: `saveMetaItem`'s D1 check and
`SysMetadataRepository.assertAllowed` refuse that binding at write time.
Hence `Clause-②: no`. The accepted set does not move, and the edit still
answers `200`.
- **Base.** `origin/main` has moved past `88a39c09`. The incoming
commits include objectstack-ai#21871, a `metadata-protocol` change to the
package-scoped list merge. It does not touch `getMetaItem`'s single-item
stamp or `findServedOverlayRow`, which this fix reads. objectstack-ai#21860 (PR
objectstack-ai#21873) has not landed, so main was not merged, per the dispatch. CI's
merge ref covers the joint state.
- **Docs.** No `content/docs` sentence describes a data-door edit
forking a package-bound set, so none is made false. The older
`permission-sets.mdx` drift about overlays of packaged sets is the one
PR objectstack-ai#21857's review already noted. It is untouched.

---
_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

2 participants