Skip to content

Commit 3b49318

Browse files
fix(metadata-protocol): one head row per /meta write address — a second unpinned package-less draft save is accepted (#22128) (#22185)
Fixes #22128 Clause-②: yes (widening) The fix restores ADR-0008's stated behaviour: an unpinned save is last-writer-wins. The `packageId` describe now states what the code already does. `@objectstack/metadata-protocol` is `minor`, because `SysMetadataRepository.headAt` is one added public method on an exported class. It is the only change to the package's public surface. The precedent is `.changeset/21986-metadata-protocol-declines-stored-row-public.md`: one added method, `yes (widening)`, `minor`. `@objectstack/rest` and `@objectstack/spec` stay `patch`. The contract review (`6053073603`) found the earlier declaration of `no` wrong on this one point. Triage's `no` came before the fix had a shape. A widening is not a break, so no ADR-0087 disposition marker applies: `check-adr-0087-registration` reads `1 non-breaking changeset(s)`. ## Direction (triage `6049925243`, binding) > One function resolves "the draft head for this address". The save's expected-parent read, the repository's lock comparison and the read's served `version` all call it. > > It resolves a package-less draft over a package-bound active row the way the repository already does. That inheritance is #11087's fix, pinned by `sys-metadata-repository.draft-package-inherit.test.ts`. > > ⛔ Not by dropping the inheritance: that re-opens #11087 (drafts lose their package). > > The `packageId` describe, which says absent means env-local, is aligned in the same PR to state the inheritance. The seat's rider `6051335917` adds two items: `?package=all` on the item read, and the `metadata_conflict` spelling in the publish route's description. Patch round 1 adds a third member of the class, which the contract review escalated: the credential carry-forward. See [the section below](#the-credential-carry-forward-patch-round-1). ## What was wrong (measured before the fix) The new real-route test `packages/rest/src/meta-draft-head-package-inheritance.test.ts` was committed first (`eb42ce01e`). It ran against a `dist/` built at the base `7ef50a4fb`, which already carries PR #22126: ``` pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/meta-draft-head-package-inheritance.test.ts Tests 6 failed | 1 passed (7) step 3: {"error":"view/case_grid has been modified since you loaded it. Expected parent null but current is hmac-sha256:1de899d7…","code":"METADATA_CONFLICT",…}: expected 409 to be 200 ?state=draft after step 2: expected null to be 'hmac-sha256:1de899d7…' ?package=all read: expected null to be 'hmac-sha256:6df7b221…' ?state=draft&package=all: {"error":"No pending draft exists for view/case_grid.","code":"NO_DRAFT"}: expected 404 to be 200 ``` The one case that passed is the env-local control, which is meant to pass both before and after. The three readers resolved three different rows: | reader | before | after | |---|---|---| | the save's expected parent (`saveMetaItem`, through `storedHeadAt`) | `repo.get` at the key it was handed. With no package, that is the package-unbound row (`package_id IS NULL`). | `repo.headAt`, which runs `resolveWriteHead` | | the read's `version` (`readVersionToken`, through `storedHeadAt`) | the same `repo.get`. The REST read door forwarded `?package=all` verbatim, so the key was `package_id = 'all'`. | the same `repo.headAt`. `all` is folded first, as the save door folds it. | | the repository's lock (`SysMetadataRepository.put`) | the active row's package, inherited for a draft that names no package (#11087), else the named key, with orphan-draft adoption as a fallback | `resolveWriteHead`, the same code moved into one function | ## The change - **The one resolution: `SysMetadataRepository.resolveWriteHead`** (private, `packages/metadata-protocol/src/sys-metadata-repository.ts`). For a write of `state` that names `packageId`, it returns the row the write upserts (`null` for a create) and the binding a create stamps. It contains #11087's draft inheritance, which reads the active row across the caller's org and the env-wide scope, and the orphan-draft adoption, both unchanged. It lives in the repository because `put`'s lock and stamp already live there, and the protocol already calls the repository. Calling it from the protocol adds no new layer dependency. Keeping it in the protocol would have made the repository call up into its caller. - `put` calls it inside its transaction, for the lock and the stamp. The inheritance lookup now runs inside that transaction too. It used to run just before it. - `SysMetadataRepository.headAt(ref, { state, packageId })` is the new public method. It answers `{ head, packageId }`. `head` is the row the write upserts, served as `get` serves a row, so its `hash` is the version `lockAccepts` accepts. `packageId` is the binding that write targets. - **The callers rewired.** - `ObjectStackProtocolImplementation.storedHeadAt` now asks `repo.headAt`, not `repo.get`. Its callers are unchanged: - `saveMetaItem`'s expected-parent read, for both the `draft` and the `publish` mode. Unpinned, the parent is the head. Pinned, the token is judged against the head (`storedParentForToken`). - `readVersionToken`, the item read's `version`, for both `?state=draft` and the uncached plain read. - `storedBodyForCarryForward` (patch round 1). Both of its reads now ask `repo.headAt`: the row the save overwrites, and with no draft yet the active row in the package the draft is stamped into. - REST: `metaItemPackageBinding(raw)` (`packages/rest/src/rest-server.ts`) is the one reading of `?package=` on the three `/meta/:type/:name` doors. Before, `PUT` and `POST …/publish` each had an inline copy, and `GET` had none. `all` and the empty value name no package. `GET`'s cache bypass (`packageScoped`) still reads the raw parameter, so `?package=all` keeps the uncached arm and keeps serving `version`. - **Spec, descriptions only.** - `SaveMetaItemRequestSchema.packageId` now states the inheritance. It also states that the same row is the one the parent-version resolution reads and the one the item read serves as `version`. The protocol reference is regenerated (`gen:docs`, through `check:generated --fix`). - The `POST /:type/:name/publish` route description now says 409 `METADATA_CONFLICT`. - `GetMetaItemResponseSchema.version`'s describe (from #22114) was re-read against the fix and still holds: "the STORED row a save to this item compares against, at this read's scope". It is unchanged. One behaviour moves on `GET /meta/:type/:name?package=all`: the served content. Before, `all` was a package address, so the read preferred a row bound to a package literally named `all`, then the package-less row. Now the read serves what a read with no `?package=` serves. That includes the draft the package-less draft save stored in the item's package, which before answered `404 NO_DRAFT`. objectui's editor sends this read (read at objectui `9990f9e`): `ResourceEditPage.tsx` scopes its draft and layered reads with the router's raw `?package=`, which does not fold `all` (its own docblock says so), while its saves fold it. ## The credential carry-forward (patch round 1) **The defect.** A save of a type whose read withholds a credential carries the stored credential back into a body that returns as it was served. Today those types are `datasource` (built in) and `flow` (with the automation plugin). The carry-forward is #8154's write-path inverse. `storedBodyForCarryForward` compared the served body against `repo.get` at the key the caller named. For a package-less draft save of a package-owned item, neither the inherited draft nor the package-bound active row sits at that key. So the comparison fell to the code layer, nothing was carried, and the stored credential was persisted away. **Measured on the real route** (the rest harness above, better-sqlite3), at head `07c229054`, before this round's fix: - The setup is a package-owned legacy datasource row whose `config.url` holds a userinfo credential. It was written straight to the store, because the save door now refuses such a URL, so a row like this only exists from before the gates. - `GET /meta/datasource/warehouse` serves it redacted. - That body is saved back with `PUT …?mode=draft` and no `?package=`, twice: 200, 200. - Each time, a system read of the stored draft row (bound to the package) shows `config.url` with no credential. - The env-local control kept the credential in both drafts. `meta-draft-head-package-inheritance.test.ts` went `1 failed | 8 passed (9)` on the pre-fix `dist/`. The failure is `expected [ false ] to deeply equal [ true ]`, on whether the first stored draft holds the credential. With the fix and `metadata-protocol` rebuilt (`grep -c 'target.packageId' dist/index.js` → 1), the file is green. Neither the PR body nor any comment carries a credential value: the pins assert a boolean. **The fix** is the same resolution (`4e63f1122`). `storedBodyForCarryForward` reads `repo.headAt` at the save's own state. With no draft yet, it reads `repo.headAt(active, packageId)` with the binding that call answered. The code-layer fallback and its `#20552` reason are unchanged. An active save's carry-forward, and the authoring gate's `restoredCredentialPathsFor`, read the exact key as before, because `headAt` adds nothing for `active`. ## Pins `packages/rest/src/meta-draft-head-package-inheritance.test.ts`: the real `RestServer` routes, better-sqlite3 `:memory:`, the real `sys_metadata*` objects. Each pin reads the store back. - Triage's sequence: `the measured sequence: active save in a package, a package-less draft, the same draft again with no If-Match → 200, 200, 200` - Triage's read pin: `?state=draft after the package-less draft serves that draft's version (the save receipt's), not null` - Triage's controls: - `control: a stale If-Match is still refused 409, with the inherited draft's token as currentVersion`. It also covers `If-None-Match: *` refused while the inherited draft exists. - `control: an env-local item with no package is unchanged — unbound rows, unpinned saves accepted, the read serves the receipt's token` - Rider pins (`?package=all`): - `active: the read's version is the token the save compares against; If-None-Match: * is refused only because a row is there` - `active, no row at the env-local address: the read serves null, and If-None-Match: * creates it` - `draft of a package-owned item: ?state=draft&package=all serves the inherited draft and its token, and the save accepts it` - Carry-forward pins (patch round 1), describe `[#22128] the credential carry-forward reads the row the draft save overwrites`: - `package-owned: the redacted read saved back as a package-less draft, twice, keeps the stored credential` - `control: env-local, the same round trip keeps the stored credential, as before` `packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts`, new describe `SysMetadataRepository.headAt — the row put locks against (#22128)`, 6 cases. They also pin the `packageId` binding `headAt` answers. - the inherited draft is the head, `get` at the named key is not, `put` refuses parent `null` and accepts the head's version; - the orphan draft is the head; - an explicit package never resolves another package's row; - an active address inherits nothing; - an org-scoped draft address resolves across the ADR-0005 reach; - a brand-new item drafted first has no head and the package-less binding. The #11087 inheritance pins in that file are unchanged and green. ## Tests and gates Every reading below is at head `ea3c748f8` (patch round 1). `origin/main` was not merged again, because the branch still merges cleanly. No file of this diff overlaps the 11 commits `main` has gained since `959c209d5`. `@objectstack/metadata-protocol` was rebuilt before the real-route runs, because `@objectstack/rest`'s tests resolve it through `exports` to `dist/`. - **The real-route pins.** `meta-draft-head-package-inheritance.test.ts`, `meta-item-version-token-occ.test.ts` and `meta-publish-package-scope.test.ts` → `Test Files 3 passed (3) · Tests 42 passed (42)`, run at `4e63f1122`, whose code `ea3c748f8` carries unchanged. That covers this card's 9 pins plus #22114's OCC pins and the `PUT`/publish `?package=` parity pins. - Before round 0's fix, the first 7 pins were `6 failed | 1 passed`, as quoted above. - Before round 1's fix, the carry-forward pin was `1 failed | 8 passed (9)`, as stated in its section. - **`@objectstack/metadata-protocol`.** - `vitest run --maxWorkers=2` → `Test Files 221 passed | 3 skipped (224) · Tests 28259 passed | 19 skipped`. That includes `protocol.metadata-redaction.test.ts` and `protocol.credential-channel.test.ts`. - `typecheck` → exit 0. - **`@objectstack/rest`.** - `vitest run --project local --maxWorkers=2` → `Test Files 262 passed (262) · Tests 4938 passed | 326 skipped`. - `typecheck` (`tsc --noEmit && check:test-typecheck`) → exit 0. - **Downstream consumer, `@objectstack/objectql`.** The 54 test files that name `saveMetaItem` / `getMetaItem` / `SysMetadataRepository` → `54 passed · 683 tests passed`. `protocol-meta.test.ts`'s `findOne` pins are among them. - **`@objectstack/spec` (descriptions only, no source change since round 0).** - Round 0's readings at `07c229054`: the 4 test files that name `SaveMetaItemRequestSchema` or the REST route table → `4 passed`, and `typecheck` → exit 0. - `check:generated` was re-run at this head and is green. - **The changeset gates.** - `check-changeset-no-major` was run with a `pull_request` event carrying this body. It answered `LEVEL AXIS: this PR declares clause-② yes (widening), and it grades a package whose packages/**/src/** it moves at minor or above`, exit 0. - `check-adr-0087-registration` → `1 non-breaking changeset(s)`, exit 0. - **Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ea3c748f8` derived 113 commands, the same set as round 0. Each was run with its exit code captured before any pipe. `--ran` gives: `113 derived famil(ies) accounted for — 111 run, 2 NOT-MEASURED`. - NOT MEASURED (exit 3, prerequisite not met, both need a full-repo build, CI runs them): - `pnpm --filter @objectstack/spec run check:skill-examples` (no `client-react` dist); - `pnpm check:dual-build-cjs-loads`. - The derivation flagged the tree as 11 commits behind `origin/main`. Two gate-source files differ there (`scripts/engine-double-contract.pinned.json`, which adds no entry for this diff's files, and a removed audit probe). - The 48-family artifact-roster block was run too, including `check:error-code-provenance`, `check:error-code-casing`, `check:engine-double-contract`, `check:spec-changes` and `check:meta-url-spelling`. 45 exit 0. The three PR-context gates are `check-partof-closing-keyword`, `check-closing-target-claim` and `check-single-claim-paths`. They were run with `PR_NUMBER=22185` and this body, and each exits 0. ## Acceptance notes Each item below is noted, not filed. - **The same `?package=all` reading on three more `/meta` doors (measured).** On the real stack, with a view whose active row is stored in package `com.probe.pkg`: - `GET /meta/view/case_grid/layers` → 200, overlay `live`. `GET /meta/view/case_grid/layers?package=all` → **404**. - `GET /meta/view` → `["case_grid"]`. `GET /meta/view?package=all` → `[]`. The layered read (`rest-server.ts`, `layeredPackageId = req.query?.package || undefined`), the list read and the book lookup each forward the literal `all`. objectui's editor (`ResourceEditPage.tsx` at objectui `9990f9e`) scopes its layered and draft reads with the router's raw `?package=`. These doors are outside this card's declared surface, so they are not changed here. The seat filed them as #22188. `metaItemPackageBinding` is the fold they would share. - **Lowercase `metadata_conflict` elsewhere.** The spelling persists in `packages/client/src/index.ts` (four docblocks), `packages/cli/src/commands/meta/delete.ts:115` (a description string), `content/docs/concepts/metadata-lifecycle.mdx:147` and `docs/qa/platform-checklist/areas/studio-authoring.json:374,376`. The rider named only the route description. `sys_metadata_audit`'s `code: 'metadata_conflict'` is the audit vocabulary's data value and is correct as it stands. - `put`'s draft-inheritance lookup now runs inside its transaction, beside the existing-row read. It used to run just before the transaction. The predicates are unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35396b5 commit 3b49318

9 files changed

Lines changed: 664 additions & 111 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/rest": patch
4+
"@objectstack/spec": patch
5+
---
6+
7+
fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential
8+
9+
Clause-②: yes (widening)
10+
11+
- **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=<id>`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again.
12+
- **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed.
13+
- **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`.
14+
- **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged.
15+
- **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it.
16+
- A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed.
17+
- The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2601,7 +2601,7 @@ Installed package with runtime lifecycle state
26012601
| **actor** | `string` | optional | Identity recorded on the write's history event (`recorded_by`, a lookup into `sys_user`) and audit row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". |
26022602
| **force** | `boolean` | optional | Destructive-change acknowledgement (`?force=true` on the REST door): skips the safety diff that refuses an `object` save whose body drops fields or narrows types the stored item still carries (409 with the findings otherwise). Only `object` saves reach that diff, so the flag is inert for every other type. Absent = the guard runs. |
26032603
| **mode** | `Enum<'draft' \| 'publish'>` | optional | Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a pending draft overlay (`?mode=draft` on the REST door; the publish door promotes it later); `publish` or ABSENT writes straight to the live `active` row — the legacy default, kept so callers that predate the draft/publish split keep working. Any value other than `draft` is read as `publish`. |
2604-
| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=<id>` on the REST door, sent only when it names a real package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent = env-local overlay (no package stamp); it also scopes which row the unpinned parent-version resolution reads. |
2604+
| **packageId** | `string \| null` | optional | ADR-0048 — the software package to bind the saved row to (`sys_metadata.package_id`; `?package=<id>` on the REST door, where `all` and an empty value name no package). Set when authoring inside a Studio package workspace; a named read-only base package is refused. On create the row is stamped with this id; on update an existing binding is preserved, never silently re-bound. Absent or `null` names no package: an `active` save writes the env-local overlay (no package stamp), and a `draft` save writes the draft of the package the item's active row is bound to when it is package-bound (a pending change stays in the package it edits), else the env-local draft. That row is also the one the parent-version resolution reads: an unpinned save takes its version as the parent (last-write-wins), a pinned one is judged against it, and the item read at the same scope serves it as `version`. |
26052605
| **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. |
26062606

26072607

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8586,12 +8586,20 @@ export class ObjectStackProtocolImplementation implements
85868586
* back. Here the stakes are higher than a phantom customization — the
85878587
* subtracted material is a credential nobody can retype from the wire.
85888588
*
8589-
* Reads the row at rest through the overlay repository, whose `get()` is
8590-
* documented VERBATIM (no ADR-0087 conversion): the comparison must be
8589+
* Reads the row at rest through the overlay repository, whose rows are
8590+
* served VERBATIM (no ADR-0087 conversion): the comparison must be
85918591
* against the bytes that were written, because those are the bytes the read
85928592
* exit redacted. {@link carryForwardRedactedValues} then decides per
85938593
* redacted path — see its docblock for the three outcomes.
85948594
*
8595+
* [#22128] The row is the one THIS save overwrites, from the repository's
8596+
* write-address resolution ({@link SysMetadataRepository.headAt}) — ⛔ never
8597+
* a read at the key the caller named. A draft save that names no package is
8598+
* stored in the package of the item's active row (#11087); read at the
8599+
* package-unbound key, neither that draft nor that active row was found,
8600+
* the comparison fell to the code layer, and the first package-less draft
8601+
* save of a package-owned datasource persisted its stored credential away.
8602+
*
85958603
* ⚠️ THE DRAFT FALLBACK IS LOAD-BEARING, not defensive. A `?mode=draft`
85968604
* save of an item with no draft row yet has nothing at its own state to
85978605
* compare against, while the body the author edited came from the ACTIVE
@@ -8621,8 +8629,9 @@ export class ObjectStackProtocolImplementation implements
86218629
}
86228630

86238631
/**
8624-
* The body a carry-forward compares against: the overlay row at the write's
8625-
* own state, the ACTIVE row for a draft save with no draft row yet (see
8632+
* The body a carry-forward compares against: the row the write overwrites
8633+
* at its own state ({@link SysMetadataRepository.headAt}), the ACTIVE row
8634+
* the draft overlays for a draft save with no draft row yet (see
86268635
* {@link carryForwardRedactedCredentials} for why that fallback is
86278636
* load-bearing), else the CODE layer. RAW in every case — the bytes the
86288637
* read exits redacted — and read with no `try`/`catch`, for the reason the
@@ -8635,15 +8644,19 @@ export class ObjectStackProtocolImplementation implements
86358644
state: 'draft' | 'active';
86368645
packageId: string | null;
86378646
}): Promise<unknown> {
8638-
let stored = await args.repo.get(args.ref, {
8647+
// [#22128] The row this save overwrites, and with no draft row yet the
8648+
// active row the draft overlays: the one in the package the draft is
8649+
// stamped into (`packageId`, the inherited binding included).
8650+
const target = await args.repo.headAt(args.ref, {
86398651
state: args.state,
86408652
packageId: args.packageId,
86418653
});
8654+
let stored = target.head;
86428655
if (!stored && args.state === 'draft') {
8643-
stored = await args.repo.get(args.ref, {
8656+
stored = (await args.repo.headAt(args.ref, {
86448657
state: 'active',
8645-
packageId: args.packageId,
8646-
});
8658+
packageId: target.packageId,
8659+
})).head;
86478660
}
86488661
// [#20552] NO overlay row at either state: the body the read served is
86498662
// the CODE layer, so that is what the incoming body is compared with.
@@ -18039,24 +18052,33 @@ export class ObjectStackProtocolImplementation implements
1803918052
/**
1804018053
* [#22114] The stored head a `/meta` save at one address compares a
1804118054
* caller's version token against: the `state` row at `ref`'s organization
18042-
* partition, bound to `packageId` (`null` = the package-unbound row), as
18043-
* the repository reads and serves it ({@link SysMetadataRepository.get}).
18044-
* `null` when no such row exists.
18055+
* partition that a save naming `packageId` (`null` = no package) upserts,
18056+
* as the repository serves it. `null` when that save would create.
1804518057
*
1804618058
* The ONE head read both halves of the ADR-0008 chain make. The save door
18047-
* judges an inbound token against it ({@link storedParentForToken}), and
18048-
* the item read serves its keyed form as `version`
18049-
* ({@link readVersionToken}). So the token a read hands out is the token a
18050-
* save at the same address accepts, and the one a receipt serves for that
18051-
* row — ⛔ never a second derivation of either.
18059+
* takes its expected parent from it — the inbound token judged against it
18060+
* ({@link storedParentForToken}), or, unpinned, the head itself — and the
18061+
* item read serves its keyed form as `version` ({@link readVersionToken}).
18062+
* So the token a read hands out is the token a save at the same address
18063+
* accepts, and the one a receipt serves for that row — ⛔ never a second
18064+
* derivation of either.
18065+
*
18066+
* [#22128] Resolved by the repository's own write-address resolution
18067+
* ({@link SysMetadataRepository.headAt}), the one its lock judges with —
18068+
* ⛔ never {@link SysMetadataRepository.get} at the named key. A draft
18069+
* save that names no package is stored in the package of the item's
18070+
* active row (#11087), so the row at the package-unbound key is not the
18071+
* row that save overwrites: read there, the parent of an existing draft
18072+
* was `null`, the repository's lock refused the unpinned save 409, and the
18073+
* `?state=draft` read served `version: null` while the draft existed.
1805218074
*/
1805318075
private async storedHeadAt(
1805418076
repo: SysMetadataRepository,
18055-
ref: Parameters<SysMetadataRepository['get']>[0],
18077+
ref: Parameters<SysMetadataRepository['headAt']>[0],
1805618078
state: 'active' | 'draft',
1805718079
packageId: string | null,
1805818080
): Promise<string | null> {
18059-
return (await repo.get(ref, { state, packageId }))?.hash ?? null;
18081+
return (await repo.headAt(ref, { state, packageId })).head?.hash ?? null;
1806018082
}
1806118083

1806218084
/**
@@ -18075,7 +18097,11 @@ export class ObjectStackProtocolImplementation implements
1807518097
* and from a package's own row to the package-less one (ADR-0048); a save
1807618098
* does not, it writes its own partition. A token of a row the save would
1807718099
* not overwrite would be refused by that save every time, so such a read
18078-
* serves `null` rather than the served row's token.
18100+
* serves `null` rather than the served row's token. [#22128] And the
18101+
* other way round: a `?state=draft` read that names no package serves the
18102+
* token of the draft a package-less draft save overwrites, which is the
18103+
* draft bound to the package of the item's active row when there is one
18104+
* (#11087) — the address the save resolves, not the key it was handed.
1807918105
*/
1808018106
private async readVersionToken(
1808118107
request: { type: string; name: string; packageId?: string },
@@ -18088,7 +18114,7 @@ export class ObjectStackProtocolImplementation implements
1808818114
type: PLURAL_TO_SINGULAR[request.type] ?? request.type,
1808918115
name: request.name,
1809018116
org: organizationId ?? 'env',
18091-
} as Parameters<typeof repo.get>[0];
18117+
} as Parameters<typeof repo.headAt>[0];
1809218118
const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null);
1809318119
return stored === null ? null : this.receiptVersion(stored);
1809418120
}
@@ -20932,7 +20958,9 @@ export class ObjectStackProtocolImplementation implements
2093220958
// package the upsert targets so a collision's other-package
2093320959
// row is never read as this item's parent.
2093420960
// [#22114] The head the item read's `version` is the keyed form of
20935-
// — one read for both halves ({@link storedHeadAt}).
20961+
// — one read for both halves ({@link storedHeadAt}). [#22128] The
20962+
// row the repository's `put` resolves for this address and locks
20963+
// against, the inherited draft binding included.
2093620964
const currentStored = await this.storedHeadAt(
2093720965
repo, ref, mode === 'draft' ? 'draft' : 'active', request.packageId ?? null,
2093820966
);

0 commit comments

Comments
 (0)