Repository navigation
fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved - #21979
Conversation
…to its own package's slot The pins drive the protocol's real list and by-name reads, and the anonymous form doors' own verdict over the env-wide view list: - (a) one package's env-wide container copy saved with a form open, another package shipping it withdrawn; - (b) the same with the withdrawal saved in the other package's copy, in both save orders; - (c) a package-less copy stands in for every package with no copy of its own; - (d) the list's slot for a package, the by-name read naming it and the list scoped to it serve the same item; - (e) a stored row of the form's own name serves its own package's slot and hides no other package's copy. On the base protocol.ts: 19 red, 4 green (the (a) control, the two (e) controls, and (e) on the unscoped kernel, where registry hydration serves the other package's copy). Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…o its own package's slot of each name The list read's view branch upserted the last expansion of a name over every package's item of that name. So one package's stored env-wide copy of a view container displaced another package's item of each name the copy expands, and the anonymous form doors, which judge against the env-wide view list, could miss that package's withdrawal of a form, shipped or saved. Each slot of a name an expansion writes now serves, for its package, the expansion of the package's own container row, else of a package-less one, which stands in for every package with no container row of its own (ADR-0048). A slot neither reaches keeps its item. The selection is one function, servedViewExpansion, whose package order is the package dimension of servedOverlayRowCandidates. The by-name read (resolveRowlessExpandedView) selects through the same function: naming a package, the package-less rows in scope stand in, as in the list scoped to that package, which now expands them too in the slots the package seats (a stand-in never seats a slot). So a package's slot in the list, the list scoped to it and the by-name read naming it serve the same expansion. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…kage's slot only The list read asked its own-row test with no package for every slot of a name, so one package's stored row of a name kept every other package's container expansion of that name out of the list. The by-name read naming the other package served that expansion, so the two doors disagreed, and the anonymous form doors could miss a withdrawal saved in the other package's copy of the container (on an environment-scoped kernel; an unscoped kernel's registry hydration happened to serve it). namesWithOwnStoredRow takes the package whose slot is being filled: a row counts when it is bound to that package or package-less (the package dimension of servedOverlayRowCandidates, shared with servedViewExpansion through addressPackages); with no package, every row counts. The list asks it per slot, the by-name read for the package it names. It is still the one predicate both doors ask. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…public form page and the intake docblock The public data collection page's "Known limit: packages and names" stated one exception for the anonymous form endpoints: a saved environment-wide copy of a view container served its expansion alone for each form it expands. The view list now serves each package's own item of such a name, so the exception and its tracking clause are removed. The over-close sentence stays. The anonymousFormIntakeWithdrawnIn docblock says the same. The changeset states the change in the view list. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 33d3749dc557e399ced101c2671d252608640654 && git checkout 33d3749dc557e399ced101c2671d252608640654
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f76c6221acd3997dd778fdd3e8e7d43e0bec4851 dc853419db59c78e54ec31404716d3a66038dc7d && git checkout -B drift-repro f76c6221acd3997dd778fdd3e8e7d43e0bec4851 && git merge --no-ff dc853419db59c78e54ec31404716d3a66038dc7d
node scripts/docs-audit/affected-docs.mjs --json f76c6221acd3997dd778fdd3e8e7d43e0bec4851
|
Contract reviewServed-tier: Inputs read: card #21967 body; comments 6011733182 (reach correction), 6012036101 (grade, direction A, stop condition), 6013043442 (unlock, done-when), 6013393991 (claim), 6014548600 (os-dev-report); PR #21979 body and file list; the net diff ① Derived judgmentsGate verdicts. All 33 completed check-runs on the head conclude 1. The env-wide view list keyed per package: RIGHT. Base The one case where the head's list holds fewer withdrawing bodies than base, judged RIGHT: a package-less copy that withdraws the form, saved beside an own copy of every package that has a slot of the name. It is reachable, because the save door leaves every row under the save name out of the siblings ( 2. The 3. Commit c966e63, 4. The doors and the save check need no change: RIGHT. 5. The by-name read's package-less rows add no new reach: RIGHT. 6. The H3 rulings, read from the comments: no contradiction found.
7. The done-when wording: RIGHT. 8. Test fidelity: RIGHT. The registry double ( ② Semver level
③ Boundary flagsDev flags from 6014548600, each answered:
Triage's pins (6012036101) both hold: (a) and (b) for a shipped and a saved withdrawal with one copy present; (d) for list and by-name agreement. The done-when of 6013043442 is met in this PR. No dogfood case was added; the doors' verdict is the composition the pins reproduce (judgment 1), within triage's "only if a door-level pin is measured necessary". Implemented-by: VERDICT: PASS Adopted by ACCEPT (seat review). The seat read the
Readings against
Generated by Claude Code |
…less sys_metadata row is served as (objectstack-ai#21990) Fixes objectstack-ai#21978 Clause-②: no ## What this changes `SysMetadataRepository` (`packages/metadata-protocol/src/sys-metadata-repository.ts`) served a `sys_metadata` row that has no `checksum` as the hash of its stored body (`rowToItem`), but `put` and `delete` judged the caller's parent against the raw column (`existing.checksum ?? null`). So a row like that could never be written or removed through the metadata door. Every `saveMetaItem` / `deleteMetaItem` answered `409 METADATA_CONFLICT` ("Expected parent hmac-sha256:… but current is null"), whether the parent was the version the door served or no `If-Match` was sent at all, because the door takes the parent from the same read. Publish, rollback and commit revert over such a row hit the same lock, and the post-promotion drain of a checksum-less draft was refused and silenced as a benign race. Per triage's direction (6014717866), with nothing narrowed and no backfill: - **One helper**, `servedVersion(ref, row)`: the stored `checksum`, else `hashSpec(body, type)`. `rowToItem` now reads it, so every read hands out this one value. - **One lock**, `lockAccepts(ref, row, parent)`, used by `put` and `delete`. It accepts the row's stored stamp, which is the old compare unchanged: a row with a `checksum` is judged exactly as before, and a `null` parent still matches a checksum-less row. For a checksum-less row it also accepts the served version. - **The conflict's head** (`lockHead`) is the served version, so a 409 on such a row names the version a read hands out (before this, `null`). A checksum-less row whose bytes do not parse keeps `null` there, so a lock refusal never becomes a parse error. - The lineage fields (`previous_checksum`, the event's `parentHash`) and the no-op check keep reading the raw stamp. So the first write over a checksum-less row, even with an identical body, stamps the row as usual. Nothing is rewritten at rest, and the header's "no backfill" non-goal stands, now with one line on how such a row is served. **File surface:** as dispatched. The producer that wrote such rows (the datasource admin door) already stamps a checksum since PR objectstack-ai#21977, which is on `main`, so the remaining work is the stored rows, and that lands in this repository class. Two test files in the same package: the pins, plus one fixture comment in `protocol-publish-drafts-package-scope.test.ts` that this change made false. Changeset: `@objectstack/metadata-protocol` patch. ## Pins (`protocol.served-content-hash.test.ts`, the existing conflict-test double) Through the protocol's real `saveMetaItem` / `deleteMetaItem` / `publishMetaItem`, on a row seeded with no `checksum`: - (a) saved and deleted with the version its read serves, in the keyed form a door hands out: the repository's own `get` read, keyed; - (a) unpinned (last-write-wins) save and delete succeed: the dogfood shape; - (b) a stale keyed token and the raw served hash are still refused with `METADATA_CONFLICT` / `409` on both doors; `actualHead` is the served token, the row is untouched, and retrying with that `actualHead` succeeds; - (c) a `null` parent still succeeds: `storedParentVersion: row.checksum ?? null`, the stored-row migration's in-process spelling; - (d) after each write the row carries `hashSpec(newBody, 'view')`; an identical re-save stamps it too; - publish over a checksum-less active row; the drain removes a checksum-less draft row; - repository level: a row WITH a checksum whose stamp differs from its body's hash refuses the body's hash and `null` (both name the stamp as head) and accepts its stamp; a checksum-less row accepts `null` and its served version, and refuses anything else with the served version as head. ## Reverse verification (committed HEAD `5c4815a6ab`) The mutation went through `scripts/ablation-replace.mjs` with an EXIT/INT/TERM restore trap and absolute paths. It restored the raw compare in both `put` and `delete` (anchor hit x2 → x0, replacement x0 → x2, blob `dc58518587` → `494fa3f0ee`; on disk, raw-compare 0 → 2 and `lockAccepts` call 2 → 0). - Predicted beforehand: 7 of the 9 new pins red, and green for the `null`-parent pin and the stamped-row pin, which guard against widening and against narrowing rather than this mutation. - **Observed: `Tests 7 failed | 16 passed (23)`**, the 7 predicted. The save door reproduced the card's text verbatim: "view/case_grid has been modified since you loaded it. Expected parent hmac-sha256:e532d121… but current is null." The drain pin read the draft row still present, and the repository pin read `actualHead` `null`. - Restore was proven by observation: blob after restore `dc58518587` equals the HEAD blob, `git diff HEAD` is empty, and `git status --porcelain` is empty. - An earlier invocation was a no-op: the tool refused with exit 2 before writing, because it located the repository from the shared checkout's cwd. On-disk counts were unchanged, and it was rerun from the worktree root. The subject is imported by relative `src` path (`./protocol.js`, `./sys-metadata-repository.js`), so no `dist/` sits on the ablation's resolution path. ## Clause-② (measured against the built entry declarations) `packages/metadata-protocol/dist/index.d.ts` was built at HEAD, and again with BASE `8a399b2b15`'s repository source swapped in behind a trap. The swap was restored and proven by blob equality, and HEAD was rebuilt, giving a byte-identical `index.d.ts`. The diff's non-comment lines are `private servedVersion;`, `private lockHead;` and `private lockAccepts;`, with 0 removed; everything else is doc text. `index.d.cts` has the identical diff. No exported type or signature moves. Behaviourally, `put` / `delete` accept for a checksum-less row the version the same repository already serves for it, which is the declared version token, not a new class of input. ## Tests and gates: all on HEAD `81606021e2` (after merging `origin/main` twice, the second bringing PR objectstack-ai#21979's `protocol.ts` change) - `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 218 passed | 3 skipped (221)`, `Tests 28028 passed | 19 skipped (28047)`. `typecheck`: `tsc --noEmit` clean, and the test file is in the program (`--listFiles` count 1). Lock VERDICT command-exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived the 63 commands, and all ran at exit 0. `check:type-check-debt` ran under the verify lock ("1 ledger entr(ies) re-measured … 26 raw tsc error(s) total, none above its recorded number"). `check:dual-build-cjs-loads` and `check:lean-entry-closure` ran after a full `turbo run build` (72 tasks, 71 cached). Reconciliation, `--ran` with per-command exit codes: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN". - The artifact-roster block (55 families, outside the total): 52 at exit 0. `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` answered NOT WIRED (exit 2, no PR context); they are rerun against this PR and reported in the `os-dev-report` comment. - The four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`): exit 0. - NOT MEASURED locally, owned by CI: the five path-scheduled CI jobs (Test Core shards, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core) and the workspace type-check lanes. `packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts` was not run locally. ## Census: writers of `sys_metadata` that can store a row with no `checksum` | Writer | Where | `checksum` | Still producing such rows | |---|---|---|---| | `SysMetadataRepository.put` (insert / update) | `metadata-protocol/src/sys-metadata-repository.ts` | always `hashSpec(body, type)` | no | | `SysMetadataRepository.delete` | same file | removes the row. Its tombstone goes to `sys_metadata_history` with `checksum: null` by design | n/a (history table) | | datasource admin door `writeDatasourceRow` | `service-datasource/src/datasource-admin-plugin.ts` | `hashSpec(record, 'datasource')` since PR objectstack-ai#21977; none before | no. Its pre-objectstack-ai#21977 rows are the stored population this PR makes writable | | datasource admin door delete fallback | same file | `update { state: 'inactive' }`, which keeps the column | no | | `DatabaseLoader` save / create / `registerRollback` | `metadata/src/loaders/database-loader.ts` | `contentHash` stamp | no | | protocol orphan adoption (`package_id` rebind) | `metadata-protocol/src/protocol.ts` | partial update, which keeps the column | no | | protocol legacy delete, permission-set overlay discard | `protocol.ts`, `plugin-security/src/permission-set-overlay-discard.ts` | delete only | no | | `env_id` → `project_id` migration | `metadata/src/migrations/migrate-env-id-to-project-id.ts` | column rename DDL | no | | stored-row migration, flow credential move | `protocol.ts` `migrateStoredMetadata`, `service-automation/src/flow-credential-migration.ts` | through `saveMetaItem` → `put` (stamps) | no. Both were refused on such rows before this PR and succeed now | | generic data door, MCP data bridge, flow write nodes, hook bodies | — | refused: `sys_metadata` declares `apiMethods: ['get', 'list']`, plus the stored-metadata family refusals | no | A tombstone reads back as a `delete` event with `hash: null` (`history()` / `rowToEvent`). `getByHash` never matches it, and `restoreVersion` refuses it with `VERSION_NOT_RESTORABLE`. **No writer is still live after this change, so no follow-up card.** ## Acceptance notes - `packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts` (about `:190`) explains its explicit `parentVersion: null` by saying a raw-seeded row's derived parent "would 409". After this change it would not; the `null` it passes stays valid. Comment drift in another package, left as is. Owner: none. - The first write over a checksum-less row records `previous_checksum: null` / `parentHash: null`, the raw stamp. That is deliberate: no history row carries the served hash, so naming it would be a parent link to nothing. - A conflict-audit note on such a row now reads "current is (withheld)" where it read "current is null", because the head is no longer null. - `DraftDrainFailure.draftHash` is documented as "the row's `checksum`". It is the served version, the same value for a stamped row. This is a doc imprecision predating this PR. - Rollback (`restoreVersion`) and commit revert over a checksum-less active row take the served parent and pass the same lock. This was read in code; only publish is pinned as the representative internal caller. - No door read serves a version token for a stored row that has no history; the tokens come from receipts, history events and a 409's `actualHead`. So for a legacy row, the 409 is the first place a client sees its token. The stale-version pin covers that retry. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21967
Clause-②: no
Another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved. This is triage's direction A (6012036101, unlocked by 6013043442): the view list's expansion upsert is keyed per package. A package-bound container row writes only its own package's slot, and a package-less row stands in for every package with no row of its own (ADR-0048). The by-name read selects through the same function, so the list and the by-name read agree.
All source edits are in
@objectstack/metadata-protocol(packages/metadata-protocol/src/protocol.ts). The other edits are a docblock in@objectstack/metadata-coreand the done-when sentence oncontent/docs/ui/public-data-collection.mdx(declared todomain:devxon #6023). Norest-server.tsedit, nopackages/specedit, no governed path.Commits (each can be read on its own)
2981f6eb78test: the pins below, committed red against the baseprotocol.ts.c457f99935the view list's expansion upsert, per package (the card's direction A):servedViewExpansion(new, module level) picks the expansion that serves a name at a package's address. The package's own container row's expansion comes first, then a package-less one's, never another package's. Within one package the last expansion still wins.servedOverlayRowCandidates([finding] getMetaItems: a package's list slot can serve the package-less row's body while getMetaItem naming that package serves the package's own row #21804's one prefer-local resolution, "no second resolver").readFlattenedMetaItems' view branch serves that function's answer for each slot of a name (the package of a listed item, or of a container row that expands it). A slot neither reaches keeps its item.resolveRowlessExpandedViewreads, when it names a package, the package-less rows as the scoped list reads them, and selects throughservedViewExpansion.c966e63a0athe own-row test (finding(metadata-protocol): a stored view row named exactly like a container expansion is shadowed in the object door by the expansion, while the by-name read answers the stored row #21510's one predicate,namesWithOwnStoredRow) takes the package whose slot is being filled. A row counts when it is bound to that package or package-less; with no package, every row counts. The list asks it per slot and the by-name read for the package it names, so it is still the one predicate both doors ask.dc853419dbthe done-when wording and the changeset. The page's "with one exception" sentence, its remedy and its "tracked in finding(metadata-protocol): a saved env-wide copy of a view container leaves its own expansion alone per name in the env-wide view list, so the anonymous form doors can miss another package's withdrawal, saved or shipped (#21934 item 1's residual) #21967" clause are removed; the over-close sentence stays. TheanonymousFormIntakeWithdrawnIndocblock says the list holds one item per package, a package's saved container copy serving that package's item. The released security(metadata): tighten the draft publish gate and package identity for org view overlays (follow-up to #21864) #21934 changeset is not edited;.changeset/21967-view-expansion-per-package.md(@objectstack/metadata-protocolpatch) states the change.Hypotheses (PM's H1 to H4), measured at base
f76c6221acprotocol.ts:9070–:9105derivedwrittenfromexpandStoredViewContainers(request.type, overlays), upserted by name into onebyNamemap (the last expansion wins), andmergeddropped every other item of the name.package_idascontainer.packageId(storedOverlayEntries,:9596).standInNames,unseated) did not encode a package-less container rule. It serves a package-less ROW of exactly the expanded name in a list scoped to a package ([finding] getMetaItems scoped to a package reads only that package's rows, so it never falls back to the package-less row that getMetaItem naming the package serves #21817). A package-less container was not expanded in a scoped list at all.packages/objectql/src/engine.ts:7159–:7165(registerItem(..., ownerId)) andpackages/metadata/src/plugin.ts:1198–:1210.resolveRowlessExpandedView(:9690) already read only that package's rows, so for two packages' containers it served that package's own expansion. Naming none, it served the last expansion in row order.getMetaItemnaming a package served that package's shipped item while the list served the package-less copy's expansion (pin (d), package-less case, red at base).expandRuntimeViewContaineris unchanged, and per-package slots make the two doors agree where they did not. Its controls (a package-less overlay of a package's own container keepsOBJECT.default, one item) pass.list.tabsand the view container's bodyname(2 keys);listViews+ ViewTabBar and the row name already deliver both #20301 (20301-spec-view-container-name-ledger-note.md): a liveness-ledger note about the container body'snamestamp. No expansion rule; untouched.<object>.default, are accepted with no diagnostic #21639 (triage 5973827435): the save door's one collision predicate, and "No merge rule: that would be a second precedence order to maintain" for two containers that collide in one selection.servedOverlayRowCandidates(addressPackages).resolveRowlessExpandedViewthe same rule").findPublicFormView(packages/rest/src/rest-server.ts:10735) andresolveFormBySlug(:10784–:10819) read the organization's list and the env-wide list.anonymousFormIntakeWithdrawnIn(packages/metadata-core/src/anonymous-form-intake.ts:336) compares names, not packages. So once the list holds each package's item, the shared verdict judges each package's withdrawal. The organization-scoped save check's first judgment (anonymousFormIntakeReopenRefusal) reads the same env-wide list. Norest-server.tsedit.Pins (
packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts, 23 cases)They are driven through the real protocol reads, as the file's existing cases are. The doors are modelled by their own composition (the organization's list, each open candidate judged over the env-wide list). The registry double holds each package's shipped container and its expanded views under
PACKAGE:NAME, as the realSchemaRegistryholds them. Every case runs on an environment-scoped and an unscoped kernel.getMetaItemnaming the package, and the list scoped to the package serve the same item.No dogfood case was added: the doors' verdict is a pure composition of
getMetaItemsand the shared function, pinned in-process as #21934's pins are.Measured
2981f6eb78over baseprotocol.ts): 19 red, 4 green. The greens are the (a) control, both (e) controls, and (e) on the unscoped kernel, where registry hydration served B's copy. Every red failed in the defect's shape, for exampleexpected [ [ 'pkg_a', true, … ] ] to deeply equal [ [ 'pkg_a', … ], [ 'pkg_b', … ] ].dc853419db, throughscripts/ablation-replace.mjs. The anchor hit once, the blob changed on disk, and each restore was proved (blob equal to HEADdef1f5213f34,git diff HEADempty). The subject is imported from source (./protocol.js), so there is nodistleg. Predictions were written before the runs.getItemanswers the hydrated expansion with the same body.protocol.tsagainst head, comments stripped:@objectstack/metadata-protocoldist/index.d.tsandindex.d.cts: identical (2162 lines each).@objectstack/metadata-coreindex.d.ts,index.d.cts,repository-*.d.tsandtesting.d.ts: identical.Clause-②: no, as claimed.@objectstack/metadata-protocolatc966e63a0a(later commits change no source in it):typecheckgreen (tsc --noEmit). Full suite in two halves: 218 files passed, 3 skipped; 28019 tests passed, 19 skipped.@objectstack/metadata-core(comment only): bothtypecheckprograms green; 18 files, 411 tests passed. The edited test file is in metadata-protocol's tsc program (counted with--listFiles).dc853419db(origin/mainis still the basef76c6221ac, so the merge is a no-op):node scripts/pm/dispatch-gates.mjs --commandswith no paths derived 93 commands from the 5 changed paths. All 93 were run and exit 0.--ranwith recorded exit codes: "93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN".PREREQUISITE NOT MET(unbuilt workspace packages):check:skill-examples,check:lean-entry-closureandcheck:dual-build-cjs-loads.check:type-check-debt's own re-measure then built the workspace, and all three were rerun green: 262 examples, 2 conditions, 106 entry points in 66 packages.check-closing-target-claimandcheck-single-claim-pathsneed this PR's context, so they run after it opens (in the report).check-partof-closing-keywordran green over this body.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchorsandcheck:adr-anchors.eslint --no-inline-config(aspnpm lintruns it): 3 files, 0 errors, 0 warnings..mdxand the changeset match no eslintfilesglob.eslint.config.mjsenables no type-aware linting (noparserOptions.projectorprojectService), and it reads only two baselines from disk, neither touched. So the diff cannot move an untouched file's verdict.grep -Pover the five paths: 0 each).check:nul-bytesis green.Acceptance notes
<object>.default, are accepted with no diagnostic #21639) reads sibling expansions with no package filter. So two different containers of two packages that expand one name are still refused at save, though the read doors would now serve them in separate slots. Stricter than the readers, and it fails closed: noted, not changed.servedOverlayRowCandidatesdoes for no package.Generated by Claude Code