Skip to content

feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion - #22126

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-22114-meta-read-version-token
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-22114-meta-read-version-token

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22114

The client half, objectstack-ai/objectui#11773 (PR objectstack-ai/objectui#11826), is unlocked by the published release that carries this change, not by this merge.

Clause-②: yes (narrowing)

yes is for the widened read member, request header and conflict field. The (narrowing) arm is for two request shapes on PUT /meta/:type/:name that were answered 200 and are now refused 400 VALIDATION_ERROR: an If-None-Match value other than *, and If-None-Match beside If-Match. The changeset carries the BREAKING line with the remedy (send * alone, never beside If-Match), and the ADR-0087 disposition not-required (no-migration-prescription): no key, export, response field or stored shape moves, no first-party sender puts If-None-Match on a PUT, and which precondition the client meant is a choice no conversion entry can derive. The level stays minor, because .changeset/pre.json is absent on origin/main (51290bca2).

What changes

ADR-0008 says clients pass If-Match with the version they read, and a client can send only a token it was served. Before this PR, only a save receipt served one. So the first save after a load could not be pinned, and two editors who each loaded and saved once overwrote each other. This PR closes the three gaps the card names, at the /meta door. Spec end and runtime end land together.

  1. The read serves the token. GET /meta/:type/:name now carries a version member, declared on GetMetaItemResponseSchema. It is the keyed token of the stored row that a save to this item compares against, at the read's scope (the caller's organization partition and ?package=) and lifecycle (?state=draft reads the draft row, the plain read the active row). It comes from the same producer as the save receipt's version and covers the same row, so a read after a save serves the receipt's token byte for byte. null means no stored row is there, so the next save is a create.
  2. "Expect no row" can be said. If-None-Match: * on PUT /meta/:type/:name saves only where no row of the target lifecycle exists. Once a row exists it is refused 409 METADATA_CONFLICT. This is the parentVersion: null pin that SaveMetaItemRequestSchema already declared, now reachable over HTTP. A save with neither header is last-writer-wins, as before.
  3. The conflict body carries the current version as data. The 409 of the /meta item write doors now carries currentVersion beside today's unchanged sentence: the token the sentence names, or null when no row of the target lifecycle exists. The body shape is declared as MetadataConflictErrorSchema, and the SDK exposes the field as err.details.currentVersion with no client change.

Where each half lands

Package Change
@objectstack/spec GetMetaItemResponseSchema.version (string, null or absent); MetadataConflictErrorSchema plus its two type aliases; the parentVersion describe now names If-None-Match: *; the receipts' version describes are corrected (below). Generated artifacts regenerated (authorable surface, json-schema manifest, api-surface, export origins, declaration map, reference docs, strictness-ledger counts).
@objectstack/metadata-protocol One head read, storedHeadAt, is shared by the save door's token check and the read's version (readVersionToken), so no second token derivation exists. getMetaItem serves version on the draft branch and on the active branch. metadataConflictRefusal, the single builder all four item doors (save, publish, rollback, reset) answer through, now also sets currentVersion.
@objectstack/rest PUT /meta/:type/:name reads its pin through metaSavePreconditionPin: If-Match as before, If-None-Match: * as the expect-no-row pin. A comment on the cached arm records why its ETag is not the token.
@objectstack/types One METADATA_CONFLICT arm in structuredCodeAnswer serializes currentVersion into the flat ADR-0112 body. This is outside the claim's declared file surface, and the reason is the repo's own rule: error-response.ts says a new bespoke code arm "belongs in structuredCodeAnswer, where both doors read it", and that function moved to @objectstack/types. The arm keys on the code and on a stated currentVersion, so a METADATA_CONFLICT that states none keeps today's body byte for byte.

No objectui change. No change to packages/client either: the read type is the spec's own GetMetaItemResponse, and the flat conflict body already reaches err.details.

Decisions taken, with the measurement behind each

  • What version names: the address of the save, not the row whose content was served. A read falls back from the caller's organization to the environment-wide row (ADR-0005), and from a package's own row to the package-less one (ADR-0048). A save does not fall back; it writes its own partition. A token for a row the save would not overwrite would be refused by that save every time. So such a read serves null, which is the honest create pin. This is pinned at the protocol: an env-wide row read under an organization serves null, and parentVersion: null is then honoured once and refused once.
  • Where version is absent:
    • On the cached published-value branch, which is the default plain read. That branch already publishes no lock, and the schema's existing contract sends OCC readers to the uncached path.
    • On ?preview=draft, a render path that mixes two lifecycles.
    • Both cases are declared in the describe, where absence means "not published here" and never "no row".
  • The ETag (H3). The cached arm's ETag stays the cache validator and is not the token. That validator is simpleHash over the scope (organization, locale) plus the served bytes, folded with the caller's field-visibility fingerprint (ADR-0106 D3) and the public-form intake fingerprint. The version token moves with none of locale, visibility or served-but-unstored bytes. An item served from code has a validator but no token. getMetaItemCached's own comment forbids hashing "a version marker" instead of the content, with get-meta-item-cached-etag-scope.test.ts §3 as its pin. So triage's "an ETag equal to it where the door sets one" cannot hold on the one branch that sets one without breaking that validator. The body member satisfies the card's "a body member, an ETag, or both". No new ETag is minted on the uncached arms: a strong validator shared across locale and mask variants would be false, and the runtime dispatcher serves the same envelope with no such header. The pin is meta-item-version-token-occ.test.ts: the cached arm has no version and its ETag is not token-shaped.
  • If-None-Match takes * alone, and never beside If-Match.
    • Measured readers and senders before this PR. On this route, rest-server.ts read if-none-match only on the cached GET. The adapters and plugin-hono-server read it nowhere. The SDK sends it only from getCached (a GET). objectui's useETagCache has zero in-repo callers.
    • A non-* value is refused 400 VALIDATION_ERROR, because a write-unless-the-head-is-one-of-these condition is one this door does not evaluate. Silently dropping a precondition the caller asked for would write that caller unguarded.
    • The pair If-Match plus If-None-Match: * is refused 400. Under RFC 9110 §13.2.2 both are evaluated, so the pair can never hold, and a 409 would send the caller round a re-read that serves a token it would pair with * again.
  • The receipts' version describes are corrected (seat round 1). SaveMetaItemResponseSchema.version, PublishMetaItemResponseSchema.version and the package publish door's published[].version (PublishPackageDraftsResponseSchema, the same receiptVersion token, so the same falsehood in the same file) said "Content hash … currently emitted as sha256:" and "409 metadata_conflict". They now say the token is the keyed hmac-sha256: digest of the stored content hash, never the hash itself, and that the conflict is 409 METADATA_CONFLICT. Only description text moves. The reference docs are regenerated; no spec test reads these describes, so none is pinned.
  • currentVersion is relayed as null, not dropped. null is the answer "no row". The arm therefore reads presence, not truthiness. The record-level CONCURRENT_UPDATE arm reads truthiness, and its producer never states null.

Tests

All pins are at the HTTP door on the real stack (better-sqlite3 :memory:, the real sys_metadata* objects, the real ObjectStackProtocolImplementation, the real routes). Every refusal pin asserts the ADR-0112 code and the HTTP status, and reads the store to show the refused write did not land.

packages/rest/src/meta-item-version-token-occ.test.ts:

  • ?state=draft serves the draft receipt's version, which survives GetMetaItemResponseSchema.parse.
  • Read, then save with the served token: 200. The next read serves the new receipt's token.
  • Two readers save in turn: the second gets 409. Its currentVersion equals the winner's receipt and the next read's version, the sentence is unchanged, and MetadataConflictErrorSchema parses the body. The loser re-pins from currentVersion and is accepted.
  • If-None-Match: *: a first draft gets 200. Once a draft exists it gets 409, with currentVersion equal to the draft's token.
  • After a publish (no draft row): a held If-Match gets 409 with currentVersion: null, and If-None-Match: * writes the next draft.
  • Control: with neither header, the second writer wins and both saves get 200.
  • Active row, uncached read: it serves the active receipt's token, and If-None-Match: * pins the create. An item registered in code serves version: null.
  • The reset door's 409 carries currentVersion too.
  • The cached arm publishes no version.
  • If-None-Match beside If-Match, or set to an entity-tag, a weak wildcard, a list, or empty: 400 VALIDATION_ERROR, nothing written. A wildcard with surrounding whitespace is still the wildcard.

Other suites:

  • packages/metadata-protocol/src/protocol.served-content-hash.test.ts: read token equals receipt token, keyed, never the stored hash; null at an empty save address; no version on previewDrafts; the refusal states currentVersion.
  • packages/rest/src/error-response-structured-arm-door-parity.test.ts: two parity cases (a token, and null) plus a control with no currentVersion, which keeps the passthrough body.
  • packages/rest/src/error-response-sandbox-arm-message.test.ts: the arm joins the derived census.
  • packages/spec/src/api/meta-item-response-shapes.test.ts: both schemas at runtime and at the type level.

Readings (repository objectstack-ai/objectstack)

Round 1, on the merged head. origin/main 51290bca2 was merged through bash scripts/pm/os-regen-merge.sh as merge commit 0bb0202bc, with parents 8f04870ef (this branch) and 51290bca2 (main). It was a clean merge: main touched none of this diff's files, and step 2 kept the branch's bytes of the 8 routed artifacts main did not move. After it came the describe fix (3950502c6) and the docs regeneration (54ae811d4). gen:schema did not run in MERGE state.

  • Gates on the merged head 54ae811d4. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 113 families against merge base 51290bca2. All 113 ran, each exit code captured before any pipe.
    • Two first readings were exit 3, PREREQUISITE NOT MET (nothing measured): check:skill-examples (no client / client-react dist) and check:dual-build-cjs-loads (37 packages with no dist). The recreated worktree had built only this diff's closure. Both read exit 0 once the battery's own full build had run.
    • --ran reconciliation: 113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero.
    • check:adr-0087-registration reads 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+clause-②-narrowing] not-required (no-migration-prescription). check:changeset-no-major reads no major bump. check:generated reports all 15 artifacts up to date.
  • Suites on 54ae811d4, under the verify lock, --maxWorkers=2, after rebuilding the closure on the merged tree:
    • @objectstack/spec meta-item-response-shapes.test.ts + protocol.test.ts: 197 tests passed.
    • @objectstack/metadata-protocol protocol.served-content-hash.test.ts: 27 passed.
    • @objectstack/rest meta-item-version-token-occ.test.ts: 15 passed; --project repo: 5 files, 191 passed and 1 skipped.
    • @objectstack/spec and @objectstack/rest typecheck: exit 0.
  • .changeset/pre.json is absent at origin/main 51290bca2, read at this push. The contents API answers 404, against a 200 for .changeset/config.json at the same ref as the control.

Round 0, on the pre-merge head (base 54ace18c6).

  • Gates. Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at head 8f04870ef: 113 families, all run there, each exit code captured before any pipe, 113 of 113 exit 0. The --ran reconciliation reads 113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN ("a DERIVED zero — all 113 recorded an exit code"). The list includes check:dispatcher-error-vocabulary, check:route-envelope, check:durability-log-level, check:engine-double-contract, check:query-options-erasure, check:spec-parsed-alias, check:adr-0087-registration, check:changeset-no-major, check:nul-bytes, check:type-check-coverage / -debt, and the spec check:* set including check:generated (all 15 artifacts up to date).
  • Package suites, real output. All runs used --maxWorkers=2 under the shared verify lock, with each package's built dependency closure.
    • At 12a0d8525:
      • @objectstack/types: test and typecheck exit 0.
      • @objectstack/metadata-protocol: typecheck exit 0; test 221 files passed and 3 skipped, 28243 tests passed.
      • @objectstack/rest: typecheck exit 0, including check:test-typecheck (0 debt); --project local 261 files and 4929 tests passed.
    • At e5e7817b8:
      • @objectstack/spec: typecheck exit 0; test 622 files and 18583 tests passed.
      • @objectstack/client: typecheck exit 0.
      • @objectstack/runtime: the 7 /meta item-read and parity suites, 812 tests passed. The dispatcher's item read serves the same protocol envelope, so it carries version too.
    • At 8f04870ef: @objectstack/rest --project repo 5 files passed. Its first run reddened the sandbox-arm census on METADATA_CONFLICT, which is the census working as designed; the arm now has its row.
  • Lint (a proven narrowing; the repo-wide pnpm lint is CI's). eslint --no-inline-config --format json over the 9 changed .ts files: 9 files, 0 errors, 0 warnings, with no ignore-pattern warning, so all 9 are inside the config's population. eslint --print-config shows no parserOptions.project or projectService, and eslint.config.mjs states it never enables type-aware linting. So this diff cannot move a verdict on an untouched file.
  • Ablations (one-off, not kept). Each leg went through scripts/ablation-replace.mjs (anchor hit 1 to 0, blob changed), and the dist legs through scripts/ablation-dist-preflight.mjs (marker present in 2 built files, then absent after the restore and rebuild). The tree reads git diff HEAD empty afterwards.
    • R, the REST door ignoring If-None-Match: * (source): meta-item-version-token-occ.test.ts 2 failed of 15.
    • P, the draft read serving no version (rebuilt metadata-protocol dist): the REST door test 5 failed of 15; protocol.served-content-hash.test.ts 1 failed of 27.
    • T, the @objectstack/types arm never firing (rebuilt types dist): 9 failed of 73 across the door test and the parity test.
  • .changeset/pre.json: absent at origin/main 15ec50e52, read before opening this PR. The changeset is a plain minor for @objectstack/spec, @objectstack/metadata-protocol, @objectstack/rest and @objectstack/types.
  • The branch is 2 commits behind origin/main (15ec50e52). Neither commit touches a file this diff touches, generated shards included. It was not merged; the queue rebuilds on main.

Acceptance notes


Generated by Claude Code

claude added 7 commits October 7, 2026 21:44
…ch: * pins a first write; the 409 carries currentVersion (WIP)

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…t's currentVersion at the door (WIP)

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…r the read token and the conflict body (WIP)

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
… reference docs and the strictness ledger counts

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-protocol, @objectstack/rest, @objectstack/spec, @objectstack/types, touching 31 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 8fc50b7647d30db2a0837877cf251c1163a3239e.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …) — pages documenting those are invisible to this run
  • 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 — 143 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 8fc50b7647d30db2a0837877cf251c1163a3239e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8fc50b7647d30db2a0837877cf251c1163a3239e

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8fc50b7647d30db2a0837877cf251c1163a3239e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 8, 2026 00:25
…ETADATA_CONFLICT; the changeset grades the two 400 refusals as a narrowing

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits October 8, 2026 01:39
…@objectstack/types; the alternate-spelling read pin names the version head read

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e2a28fadf1c02a48c965f0303776872e1a323b79
Local-runs: none

PR #22126 (card #22114), branch claude/issue-22114-meta-read-version-token, draft, head repo = base repo, base main at 8fc50b7647d30db2a0837877cf251c1163a3239e, 12 commits, 20 files, +899 / -43 (942 changed lines). Inputs read: the card body and all 7 comments (triage, claim, three dev reports, the round-1 patch order, the ACCEPT), the PR body, its file list, its one comment (docs drift, advisory), the net diff main...head, and the head's check-runs. Source at the head was read through the contents API only: protocol.ts, rest-server.ts, metadata-redaction.ts. Nothing was built, run or re-run.

Check-runs on the head (REST check-runs API, both reads): first read at 2026-10-08T02:54:41Z — 39 runs, 33 success, 5 skipped, 1 in_progress (Check Changeset, the re-run the labeled event started); second read at 2026-10-08T03:01:10Z — 39 runs, 34 success, 5 skipped, 0 failed, 0 in progress. All seven required contexts success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The five skipped are the opt-in tarball smoke, Console Pin Gate and the labeled-event duplicates of Auto Label / Check PR Size. No path in the file list is a governed surface; the review is owed on the Clause-②: yes limb and on the published-schema path limb (packages/spec/src/api/protocol.zod.ts, error-code-ledger.zod.ts, non-test).

① Derived judgments

  1. GetMetaItemResponseSchema.version — string | null, optional (protocol.zod.ts). A widened response accept set: a non-strict z.object used to strip the key, a parse now keeps it. Right, and the describe's three states match the runtime at the head: the draft branch always sets it (protocol.ts draft return); the active branch sets it unless previewDrafts (absent), and serves null when no stored row was found in scope or when the save's own address holds none. readVersionToken builds getOverlayRepo(orgId ?? null), ref = {singular type, name, org: orgId ?? 'env'} and storedHeadAt(repo, ref, state, packageId ?? null); the save door builds the identical four facts at its own storedHeadAt call (orgId = request.organizationId ?? null, singularTypeForRepo, org: orgId ?? 'env', packageId ?? null). One head read, one producer (receiptVersion), so the read's token is the save's comparand byte for byte. The read side's orgId is organizationIdForMetaRead(type, organizationId) and the REST PUT folds its scope through organizationIdForMetaWrite before the protocol, so at the HTTP door both halves address one partition. The REST GET uncached arm passes packageId = req.query.package || undefined plus state / previewDrafts / organizationId; the cached-arm selector diverts ?state=draft, ?preview=draft, ?package=, app / dashboard / book / doc types and enableCache: false deployments to that arm, so Studio's draft and package-scoped reads serve version, and the default cached plain read does not (its ETag stays the cache validator; pinned). Meets the card's Done-when ("at least state=draft, and the stored-row read a client edits from").

  2. New public exports MetadataConflictErrorSchema, MetadataConflictError, MetadataConflictErrorParsed from @objectstack/spec/api. A public-surface widening. The eight generated artifacts moved in step: api-surface +3, export-origins +3, declaration-map +2, json-schema.manifest +1, authorable-surface +4 (GetMetaItemResponse:version, MetadataConflictError:{code,currentVersion,error}), strictness-ledger count 433 → 434, reference docs; check:generated sits inside the green required jobs. Right. The schema declares currentVersion REQUIRED (string | null) and code as the one literal. Judged against the producer: metadataConflictRefusal sets currentVersion whenever servedContentHash(actualHead, digest) is not undefined; storedHashDigest() always returns a digest (the provider's keyed digest or the ephemeral process key), and servedContentHash is undefined only for a non-string non-null head, which the repository does not produce — so every refusal the builder composes states it. All five metadataConflictRefusal call sites at the head are the four item doors (save, both the token-resolution and the repository-race site; publish; rollback; reset). The schema's scope note excludes the batch publish's failed[] rows and raw repository shapes, and the @objectstack/types arm keys on presence, keeping the passthrough body for a METADATA_CONFLICT that states none (the parity control). Right.

  3. PUT /meta/:type/:name request accept set (metaSavePreconditionPin, rest-server.ts).

    • If-None-Match: * (whitespace tolerated) → parentVersion: null, the first-write pin SaveMetaItemRequestSchema already declared, now reachable over HTTP; refused 409 METADATA_CONFLICT once a row of the target lifecycle exists. A widening. Right, pinned on draft and active.
    • If-None-Match with any value other than *, and If-None-Match beside If-Match → 400 VALIDATION_ERROR, nothing written. Before the head the door did not read the header, so these requests were answered 200 and written: a narrowing of a published door's accept set, and the dev's original (widening) grade for it was wrong; the round-1 regrade to narrowing is right. Refusing rather than dropping is right (a dropped precondition writes a caller unguarded who asked for a guard — route rule 5's reason); the pair refusal is right under RFC 9110 §13.2.2 (both are evaluated, the pair can never hold, and a 409 would loop the caller through a re-read). The 400 body is the nested ADR-0112 form this route's other VALIDATION_ERROR refusals use; check:route-envelope is green. The closed value set is declared the day the header is first read on this route.
    • If-Match alone (quotes stripped) and neither header (last-writer-wins): unchanged, with the control pinned. Right.
  4. @objectstack/types structuredCodeAnswer — the METADATA_CONFLICT arm. Serializes currentVersion (presence-keyed; null relayed as the answer "no row") into the flat 409 body on both doors, keyed on the code AND a stated currentVersion; the door-parity test (token, null, control) and the sandbox-arm census row cover it. A published behaviour change of @objectstack/types, so its minor is right. error-code-ledger.zod.ts registers the stamp site under the @objectstack/types owner key — provenance only, the ErrorCode union unchanged — which is what turned check:error-code-provenance green on this head after the red on 54ae811d4. Right.

  5. Describe-only corrections on SaveMetaItemResponseSchema.version, PublishMetaItemResponseSchema.version, PublishPackageDraftsResponseSchema.published[].version and SaveMetaItemRequestSchema.parentVersion: no type or accept-set change; the text now names the served hmac-sha256: token and the METADATA_CONFLICT wire code, and the regenerated reference docs carry the same words. Right.

  6. packages/objectql/src/protocol-meta.test.ts: the count-of-2 pin became the exact read sequence (app, apps, then the save-address head; version: null because the served row sits under the other spelling). Test only; the third read is the one extra keyed findOne the PR declares as cost. Right.

  7. Unchanged, as the PR states: GET …/layers serves no version; the DELETE door reads no If-None-Match; packages/client is untouched (the read type is the spec's, and the flat conflict body already reaches err.details); the runtime dispatcher's item read carries version because it rides the protocol envelope (parity suites green). No lenient alias or consumer fallback was added anywhere in the diff.

② Semver level

  • Changeset .changeset/22114-meta-read-version-token.md: @objectstack/spec, @objectstack/metadata-protocol, @objectstack/rest, @objectstack/types — all minor. Every published package the diff moves is named; the other touched trees (packages/objectql test only, content/docs/references/**, docs/audits/**) publish nothing. Matches the diff.
  • The PR body and the changeset both carry the line reading Clause-②: yes (narrowing). A legal combination of the closed grammar: yes for the widened read member, new exports, request header and conflict field; (narrowing) for the two PUT /meta request shapes now refused 400. Right — yes takes at least minor (held), and the arm is BREAKING, so the changeset owes the two launch-window carriers: the **BREAKING** paragraph names both refused shapes and the remedy (send * alone, or If-Match with the version read, never both), and exactly one ADR-0087 marker reads not-required (no-migration-prescription). The category is right: no authorable key, export, response field or stored shape moves, so there is nothing for objectstack migrate meta to rewrite, and which precondition a sender meant is client intent no conversion entry can derive. check:adr-0087-registration and check:changeset-no-major run inside the green Lint & Repo Gates / Check Changeset.
  • Level minor, not major: the launch-window convention (check-changeset-no-major.mjs) ships a breaking change as minor and refuses a major outside pre-mode; .changeset/pre.json is absent on main at the PR's base 8fc50b764 (contents API 404, against 200 for .changeset/config.json at the same ref — read by this review, not taken from the report). Right.
  • The card claim's arm-less Clause-②: yes is not the carrier; the PR body is, and it reads the arm.

③ Boundary flags

Implemented-by: claude/issue-22114-meta-read-version-token
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 03:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 03:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 8f2e808 Oct 8, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22114-meta-read-version-token branch October 8, 2026 03:39
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… verdict (objectstack-ai#22032 pass 3) (objectstack-ai#22151)

Part of objectstack-ai#22032
Clause-②: no (narrowing)

This is pass 3 of objectstack-ai#22032: a field option's `visibleWhen`. Pass 4 (the
object's own action predicates) stays fenced, and the card stays open
for it.

## What changes

**The object save door gives the build's verdict on a field option's
`visibleWhen`.** `formulas.mdx` says "the same `validateExpression`
validator backs `os build` and metadata registration". After pass 2 the
object door ran the whole field walk except the per-option loop, which
kept its own guard. So an object whose option carried `visibleWhen:
'amount > 1'` (a bare field reference) still saved with a 200, while `os
build` refused it at error. The server's option check cannot evaluate
such a predicate and fails open (logged, allowed through), so the gate
it declares is never enforced (read from `evaluateOptionVisibility` in
`packages/objectql/src/validation/rule-validator.ts`).

- **The lift is the guard, nothing else (H1 held).** On `origin/main`
`8fc50b7647` the loop read `for (const [oi, opt] of (objectWrite ? [] :
recordsOf(f.options)).entries())` (`validate-expressions.ts:2072`),
under a `[objectstack-ai#22032] FENCED on an object write (pass 3 …)` comment. It now
reads `recordsOf(f.options)`. On an object write the loop runs at the
build's own position in the field walk, so the door gets both of the
option's checks:
  - `check(optionWhere, opt.visibleWhen, objectName, 'record')`;
- `refuseFieldTraversal(optionWhere, 'option visibleWhen', …)`, the
refusal of a read through a reference field on `record` or `previous`.
- **`current_user` keeps the build's two verdicts (H2).** An option's
evaluator binds the acting user (ADR-0068 D1), so the build accepts
`current_user` on an option and refuses it on the field-rule slots one
level up. The door now gives both verdicts as the build does. The
showcase's role gate, `'org_admin' in current_user.positions`, still
saves on an option, and the same text on the field's own `visibleWhen`
is refused at both doors. Both are pinned.
- **No registry change (H3 re-verified).** The
`validateStackExpressions` entry declares `runtimeTypes: ['flow',
'action', 'hook', 'object']` (`authoring-rules.ts`), and
`runtimeAuthoringRulesFor('object')` (`runtime-gate.ts`) dispatches it.
`runtime-gate.ts` is untouched.
- **Docblocks made true.** `StackExpressionOptions.runtimeWriteType` now
names four admitted passes and one fenced pass.
`AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one
line that reaches a built `.d.ts`, names the per-option pass. The
function-head comment and the field walk's two comments move with it. In
`authoring-rules.ts` the registry entry gains a `[objectstack-ai#22032, pass 3]`
measurement comment, as passes 1 and 2 added theirs. Comments in four
sibling test files are corrected so that none of them still says option
`visibleWhen` is fenced.
- **The door's verdict is the build's finding (H4).** The door's 422
issue and `runAuthoringRules('build', …)` give the same rule
(`expression-invalid`), location (`object 'fx_option' · field 'province'
option 'zj' visibleWhen`), path, message and hint. The pins compare
these key by key.
- **No code change in `packages/metadata-protocol`.** Only its test file
gains the door-level pins.

## Pins

- **Lint door:**
`packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts`
(new, 14 tests).
- LIT: one refused body per finding the pass gives. These are a bare
`amount > 1`, an unregistered `sqrt(record.amount) > 1`, an unknown
field `record.amont > 1`, a syntax error `record.country ==`, and the
traversal refusal through `record.account` and through
`previous.account`. Each is located at the option and asserted on its
named subject.
- CONTROL (the still-accepted cases): the `record.country` cascade, the
showcase's `current_user.positions` role gate, a grant check plus role
gate (`current_user.can(…) && …`), and a reference compared as a value
(`record.account != null`). Each is clean at the door and at the build.
- CONTRAST: `current_user` on the option and on the field's own
`visibleWhen` in one body. The build and the door both give exactly one
finding, at the field slot.
- PARITY: for each refused body, the door's findings equal the build's.
- The differential: a stored sibling's broken options are not this
write's to answer for.
- **The fence (enumeration pin)** in
`packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The
fenced site is now pass 4's alone (an action `visible`). The option
`visibleWhen` site moves to the lifted sites, beside the validation rule
and the `requiredWhen`. The build flags all four sites. The object door
flags the three lifted sites in the build's order, and
`runStackExpressionPasses` on an object write returns exactly the
build's findings for the admitted passes.
- **Protocol door:** a new pass-3 block in
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
through the real `saveMetaItem`, `publishMetaItem` and
`publishPackageDrafts`.
- (a) A bare reference, an unregistered function and a read through a
reference field are each refused on an active save. The answer is a 422
`INVALID_METADATA` carrying the build's located finding, and nothing
lands.
- (a) The card-shaped body is refused on a draft's promotion and on a
package draft publish (`outcome: 'refused'`, `failed` naming the object
with `INVALID_METADATA`, the row left a draft). The draft saves
themselves still succeed.
- (b) The showcase's cascade and its `current_user` role gate still
save, and the row lands active. The role gate rides every refused body
too, which each yield exactly one finding.
- (d) For each refused body, the door and `os build` give the same
finding on rule, where, path, message and hint.

## Reverse verification (one-off, from committed HEAD `23ce6c494c`)

- **What was mutated.** `scripts/ablation-replace.mjs` (wrap mode) put
the guard back on the option loop. The new text was `const
ablationFence22032p3 = objectWrite;` followed by `for (const [oi, opt]
of (ablationFence22032p3 ? [] : recordsOf(f.options)).entries()) {`. The
anchor was hit once, 1 to 0, and the blob went `879fcb828037` to
`73bd026d1554`. The outer script carried `trap restore EXIT INT TERM` on
the absolute path.
- **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and
`ablation-dist-preflight` found the marker in 4 built files.
- **Lint suites (source): 9 failed and 14 passed, as predicted.**
- Red: the 6 LIT tests, PARITY, and the two fence tests that assert the
lifted sites.
- Green: the 4 CONTROL tests, CONTRAST, the differential, the registry
test, the build-flags-each-site test and the six formula-door tests.
- **Protocol pass-3 block (dist-mediated): 6 failed and 1 passed, as
predicted.** Red: the three (a) saves, (a) on promotion, (a) on package
publish, and (d). Green: (b).
- **Restore.** The tool restored the file: blob `879fcb828037` equals
HEAD, and `git diff HEAD` is empty. The whole tree had 0 changed paths.
Lint was rebuilt, and `--absent` found the marker gone from all 14 built
files. Both suites went green again: lint 23 of 23, and the protocol
file 99 of 99.

## Measurements

- **Corpus first: the stop condition was not met (H5).** Every object
this tree ships was judged before the door changed. That is every
`*.object.ts` under `packages/**` and `examples/**` (111 files) plus the
two `app-multi-package` sub-stacks: 118 objects in 18 groups. Each was
judged at the raw shape and at the `ObjectSchema.parse` shape (0 parse
failures), with its own group as context.
- 5 option predicates on 2 fields of 1 object, all on
`showcase_cascade`: `province`'s four `record.country` cascades (`zj`,
`gd`, `ca`, `tx`) and `tier`'s `restricted` role gate (`'org_admin' in
current_user.positions`).
- At base `8fc50b7647`: 0 build errors and 0 build warnings for the
option pass, through `validateStackExpressions` and through
`runAuthoringRules('build')`, at both shapes. There were 0 door
expression findings over all 118 objects.
- Non-vacuity: the 5 sites are judged. Mutating one cascade to a bare
`country` and the role gate to `sqrt(record.amount) > 1`, in a copy,
gave 2 build errors at those two options.
- At head `23ce6c494c`, and again at the merged heads `06d3cad963` and
`3c1d3262ee`: 0 door errors and 0 door advisories over every object,
through `runRuntimeAuthoringRules` with type `object`, at both shapes.
The harness passes each object's own group as context, so at
`3c1d3262ee` every one of those saves is an update and also runs the
stored-universe pass that objectstack-ai#22118 added.
- Positive control in the same harness (an option `visibleWhen: 'amount
> 1'`): 1 build error at every head. The door gave 0 at base and 1 at
head.
- **Which doors newly answer 422.** The active publish save, a draft's
promotion, and a package draft publish. Each was measured through the
real methods above. A draft save stays ungated, measured by the same
pins.

## Clause-② (measured)

- **Accept set: narrowing.** An object write in publish mode answered
200 for an option `visibleWhen` the validator refuses. It now answers
422 on the three doors above.
- **Built entry declarations.** In `@objectstack/lint` one doc comment
moves (`AuthoringRuleContext.runtimeWriteType`).
`StackExpressionOptions` and `runStackExpressionPasses` are not in the
built declarations. No exported signature moves.
- **Changeset.**
`.changeset/22032-object-save-door-option-visible-when.md` covers
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It
carries `fix(lint)!`, the `Clause-②: no (narrowing)` line, a BREAKING
section with the remedy, and ADR-0087 `not-required
(no-migration-prescription)`. `@objectstack/metadata-protocol` is listed
as passes 1 and 2 listed it, although no code moves there: its save,
promotion and package-publish doors are where the BREAKING behaviour can
be seen. `.changeset/pre.json` is absent on `origin/main` (read at
`8fc50b7647`, 2026-10-08T01:54Z, at `ef1fcb26a2`, 2026-10-08T02:40Z, and
at `7ef50a4fbb`, 2026-10-08T03:39Z), so the bump is `minor` with the
BREAKING banner, as in passes 1 and 2. The changeset says it supersedes
the earlier objectstack-ai#22032 entries' line that option `visibleWhen` is not judged
at this door.

## Merges

- **`06d3cad963`** merges `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103),
through `scripts/pm/os-regen-merge.sh`. It was clean.
- **`3c1d3262ee`** merges `origin/main` `7ef50a4fbb` (parents
`06d3cad963` and `7ef50a4fbb`), through `scripts/pm/os-regen-merge.sh`.
That brought PR objectstack-ai#22129 (objectstack-ai#21982), PR objectstack-ai#22094, PR objectstack-ai#22134, PR objectstack-ai#22133
(objectstack-ai#22118, the gate's object-write baseline keeps the written item's
stored self), PR objectstack-ai#22126 and PR objectstack-ai#22140.
- `validate-expressions.ts` auto-merged. PR objectstack-ai#22129's edit is in the flow
`script` / `subflow` region; the option loop's lift is unchanged.
- One conflict:
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
one hunk. Both sides had added a new top-level `describe` block at the
same place, after the pass-2 block. It was resolved by stacking: the
pass-3 block first, then objectstack-ai#22118's stored-self block, and every line of
both was kept. Against `7ef50a4fbb` the file shows only this branch's
lines; against `06d3cad963` it shows only objectstack-ai#22118's 114 lines.
- No generated path was touched by this branch. The rerun of the script
took `origin/main`'s side of every generated path main moved, and that
left nothing to commit.
- This branch's own changes are the same before and after the merge: for
each of the 9 files, the added and removed lines against the merge base
are identical. The delta against `7ef50a4fbb` is 9 files, +467 / −45.
- **The interaction with objectstack-ai#22118, measured.** objectstack-ai#22118 adds a third,
stored-universe pass on an update into a context collection. A finding
whose path names no entry is judged as one on the written item. The
expression rule's paths are `where` strings, so an option finding is
always judged that way.
- A probe ran the real gate (`runRuntimeAuthoringRules`, type `object`)
over 20 cases, against this branch's lint source before the merge
(`06d3cad963`) and after it (`3c1d3262ee`). The cases are a create, an
update over a stored self that is broken and over one that is clean, and
a stored sibling that is broken, each for three refused bodies, plus two
still-accepted bodies.
- The verdicts are identical, case for case. Re-saving a broken option
is still refused, including over a broken stored self, because re-saving
a row is writing it. A clean save over a broken stored self passes. A
broken sibling is never charged.
- The pass-3 pins (differential, PARITY, LIT, CONTROL, CONTRAST) and
objectstack-ai#22118's pins all pass at `3c1d3262ee`: lint 48 of 48 across the three
files, and the protocol file 103 of 103.
- This matches the code. The option pass reads only the written object's
own field index, and binds `record` and `previous`, never `parent`, so
the stored universe has nothing extra to offer it.

## Tests and gates (all at `3c1d3262ee`, the merge of `origin/main`
`7ef50a4fbb`)

- **`main` moved during the run (H6).** PR objectstack-ai#22103 landed as `ef1fcb26a2`
and touched two files this pass edits, `authoring-rules.ts` and
`runtime-gate.object-writes.test.ts`, in other hunks. It was merged with
`scripts/pm/os-regen-merge.sh` as a merge commit. The merge was clean,
and no generated path was taken from either side. After the merge: `pnpm
install --frozen-lockfile`, a full turbo build (72 tasks), and
`@objectstack/spec check:generated` ("All 15 generated artifacts are up
to date"). objectstack-ai#22118 and objectstack-ai#21982 had not landed at that read
(2026-10-08T02:40Z). Both have since landed, and they are merged at
`3c1d3262ee` (see Merges). After that merge the same sequence ran: a
full turbo build (72 tasks) and `check:generated` ("All 15 generated
artifacts are up to date").
- **`@objectstack/lint`:** 125 files and 5729 tests passed. `typecheck`
exit 0, with its test-typecheck included (`--listFiles`: the five
touched or new lint test files are in the `tsconfig.test.json` program).
- **`@objectstack/metadata-protocol`:** 221 files passed and 3 skipped;
28260 tests passed and 19 skipped. `typecheck` exit 0 (`--listFiles`:
the door test file is in the program).
- **Consumer readings.** Every package was built at the head named.
- `@objectstack/objectql`, 8 files and 282 tests passed:
`publish-package-drafts-response-conformance`,
`save-meta-response-conformance`, `publish-meta-response-conformance`,
`plugin.integration`, `engine-field-predicate-fault`,
`engine-option-permission-predicate`,
`validation/rule-validator.option-visibility` and `engine`.
- `@objectstack/rest`, 11 files and 197 tests passed: every
`meta-object-*` file and `meta-publish-package-scope`.
- `@objectstack/cli`, 3 files and 16 tests passed, run as `--project
integration` because the tier predicate puts them there:
`validate-field-predicate-traversal` (which asserts an option
`visibleWhen` `expression-invalid` finding),
`authoring-rule-command-parity` and `verify-author-time-stage`. The
three nightly-tier `*.e2e` files that assert `expression-invalid` are
left to CI.
- The search for other consumers covered every test file in the
repository carrying `visibleWhen`, matched against the save-door entry
points. Only the two packages above save an option `visibleWhen` through
a door.
- **Gates.** `dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands, the same set at
`23ce6c494c`, `06d3cad963` and `3c1d3262ee`. At `3c1d3262ee` all 63 exit
0, each exit code captured before any pipe. `--ran` reconciles 63
derived, 63 run, 0 NOT-MEASURED and 0 UNRUN, with an exit code recorded
for each. The printed artifact-roster block names 51 families, and all
51 ran at `3c1d3262ee`, each with exit 0. That is 47 in the same
battery, one (`check:engine-double-contract`) already among the 63, and
the three PR-scoped ones (`check-partof-closing-keyword`,
`check-closing-target-claim`, `check-single-claim-paths`) run with this
PR's number and this body. The same 63 also ran at `06d3cad963`, all
exit 0. At `23ce6c494c`, before the merge, the same 63 ran: 61 exited 0
on the first run. `check:dual-build-cjs-loads` and
`check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no full
build) and exited 0 after the full build.
- **ESLint, narrowed to the 8 touched TypeScript files**
(`--no-inline-config --format json`): 8 files, 0 errors and 0 warnings.
Each file is matched by `eslint.config.mjs` (`--print-config`), and none
was ignored. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file.

## Acceptance notes

- **Out of this pass, reported for the seat: an option `visibleWhen`
reading `parent` gets no verdict at the build, so none at the door
either.**
- Measured at `23ce6c494c` with scratch tests that were deleted
afterwards. Through the real `saveMetaItem`, an object whose option
carries `visibleWhen: "parent.status == 'closed'"` saved with success,
and the row landed `active`. `runAuthoringRules('build')` gave 0
findings.
- The server's option check (`evaluateValidationRules`, authenticated
caller, the option picked) then logged `failed to evaluate
(authenticated caller) — allowed through`, with `Unknown variable:
parent`, and admitted the value. The option evaluator binds `record`,
`previous`, the user and permissions only.
- This is a gap in the build, which the door now mirrors. This card's
contract is parity with the build, so it is not changed here.
- **A code comment names an old spelling.** The comment above the option
loop calls the showcase's legal usage `'admin' in
current_user.positions`. The showcase now writes `'org_admin' in
current_user.positions`, and the pins use that spelling. The comment is
not changed here: per the contract review, it rides pass 4.
- **The pending objectstack-ai#22032 changesets.** Pass 1's and pass 2's changesets
each list option `visibleWhen` under "Unchanged", which was true at
their heads. This pass's changeset says it supersedes that line rather
than editing them, the same way pass 2 left pass 1's changeset alone.
Per the contract review, reconciling those lines rides pass 4.
- `formulas.mdx` could name the object save door. That would be a docs
addition, not a correction of a false line.
- **Contract review.** Triage's grade asks for one per pass. A PASS is
on record for head `06d3cad963` (`6051573476`). The head has since moved
to `3c1d3262ee` by the merge above, so the review for this head is the
seat's.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…nd unpinned package-less draft save is accepted (objectstack-ai#22128) (objectstack-ai#22185)

Fixes objectstack-ai#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 objectstack-ai#11087's fix,
pinned by `sys-metadata-repository.draft-package-inherit.test.ts`.
>
> ⛔ Not by dropping the inheritance: that re-opens objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 `objectstack-ai#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 `[objectstack-ai#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
(objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… cited decision in words instead of a tracker number (stage 29) (objectstack-ai#22376)

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

Stage 29 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 second name-ordered group of the test files
directly in `packages/spec/src/`: 15 files,
`root-entry-migrations-split.pin.test.ts` through `stack.test.ts`. 12 of
them carried 57 tracker ids in test titles (59 ids), one of which sits
in a declared describe label rather than a literal title. All 57 now
either state what their record decided, in words (form D), or drop the
number where the title already says it. Text only: no assertion,
identifier, test count, code comment, file name or non-test file
changes. No file directly in `src/` carries an id in a string any more
(`type-alias-convention.pin.test.ts`, which sorts after the group, reads
0); the card stays open for the subdirectory files named under
Acceptance notes.

## Census (re-taken first)

The instrument is stage 28's `census28.cjs`, byte-identical (md5
`31d8488b5194b8d3048e3fcaec0efaed`, the value stage 28 published): an
AST walk over the `packages/spec/src` test files, one message per folded
string (a lone literal, a template, or a plus chain) that matches the
gate's id pattern, a title when the folded root is argument 0 of a
describe / it / test / suite / bench call, comments never read. It was
run against the three published readings before it was trusted, and all
three reproduce exactly: 128 messages / 130 ids in 37 files at stage
27's landing `f7b8a5932b`, 191 / 200 in 53 files (titles 162 / 168,
other 29 / 32) at `aa09db58c9`, and 73 / 76 in 24 files at stage 28's
landing `b7e01fbbd`.

| reading | messages / ids | files |
|:--|--:|--:|
| the group, base `43fc50051` | 57 / 59 (titles 56 / 58, other 1 / 1) |
12 of 15 |
| class (e) whole, base `43fc50051` | 73 / 76 (titles 61 / 64, other 12
/ 12) | 24 |
| class (e) whole, this head | 16 / 17 (titles 5 / 6, other 11 / 11) |
12 |
| the group, this head | 0 / 0 | 0 of 15 |

The group reads 57 / 59, stage 28's landing figure: no re-cut. The
census at `3f80f1716` (the dispatch base) and at stage 28's landing read
the same per file as at `43fc50051`.

Per file, messages at base: root-entry-migrations-split.pin 1,
stack-artifact-crossref 11 (12 ids), stack-artifact-packages 3,
stack-cross-reference-envelope 2 (one title, one declared label),
stack-dev-logins 4, stack-email-template-locale-floor 6,
stack-inline-action-crossref 13, stack-json-stage-package-body 4,
stack-refusal-envelopes 2, stack-requires 4 (5 ids),
stack-top-level-strict 5, stack 2. `stack-conversions-record`,
`stack-duplicate-action-key` and `stack-provenance` carry no id in any
literal.

Controls:
- Pathspec: the 15 named paths hit the control word `describe(` in 15 of
15 files, and a nonsense word in none; the census scanned 15 of 15.
- Lit outside the group, at base and head: `ai/build-progress.test.ts` 2
/ 2 and `ui/dashboard.test.ts` 2 / 2.
- Lit at base and dark at head, inside the group:
`stack-artifact-crossref` 11 / 12, `stack-inline-action-crossref` 13 /
13, `stack-requires` 4 / 5 and `stack-cross-reference-envelope` 2 / 2
each read 0.
- Dark at both ends: `stack-conversions-record` has no id anywhere;
`stack-duplicate-action-key` and `stack-provenance` carry an id in one
comment line each and read 0. The 15 group files read 0 at head while 55
of their comment lines still carry ids (112 id-bearing lines at base,
comments included, 14 files).
- Planted, in a scratch tree: an id in a describe title, a plus-chain
title, an expect message, an it.each row name, a template title and a
declared string each read once (6 / 6); a comment, a six-digit number,
an HTML entity and a markdown heading read 0.

## What changed

57 literals, one line each, in 12 files: +57 / -57. Every file keeps its
line count.
- 6 state the decision in words: "a modal target names a page, only" for
the inline and the embedded modal-target refusals
(stack-inline-action-crossref 133 and 330); "it was never built by a
stack producer" for the hand-built stack object composeStacks refuses
before either artifact-scoped rule runs (stack-artifact-crossref 380);
"compatibility with the multi-package artifact" (stack-artifact-crossref
270); "unchanged from before the dangling-`objectName` refusal"
(stack-inline-action-crossref 399); and the declared label
"hooks[].object (the hook-ownership rule)", the rule ADR-0130 states as
"the split must follow hook ownership" (stack-cross-reference-envelope
91).
- 4 get their subject back where the number was the only subject: "the
no-floor report: warn-once bookkeeping" and "the floor guard's declared
scope boundary" (stack-email-template-locale-floor 159 and 179), "the
strict top-level door: the accept side does not move" and "defineStack
surfaces the unknown-top-level-key refusal" (stack-top-level-strict 173
and 225).
- 47 drop a number the title already explains, with its connector: a `#N
— ` or `#N ` prefix, a `[#N] ` prefix, or a trailing `(#N)`. That
includes the twin title `stack-artifact-packages.test.ts:355`, which
read `does not warn about an undeclared composition rule (objectstack-ai#5005 rule 3)`
and now reads `does not warn about an undeclared composition rule`, the
same text stage 28 gave its copy at
`compose-stacks-manifest-preserve.test.ts:308`.

## Cited records

26 distinct ids. 25 answer 200 and were read: the body and the comment
thread as the API serves it, with the rulings and landings that the
rewritten titles state read in full. objectstack-ai#10485 answers 404; its decision
was read from its landing commit `35ad101bc` (the `themes` carrier
retired under ADR-0049 enforce-or-remove; `app.branding` is the one
colour surface) and from the unknown-key strictness ledger, which
records the maintainer's 2026-08-21 disposition as retiring the
authorable surface, the phrase the title keeps verbatim. The comments
counter of four records differs from what the comments endpoint serves
(objectstack-ai#14122 serves 21 of 22, objectstack-ai#17614 5 of 6, objectstack-ai#16449 0 of 5, objectstack-ai#18056 5 against
a counter of 4); everything served was read, and objectstack-ai#16449's decision is in
its body.

What each record decided, as the titles now say it:
- objectstack-ai#6739, ruling A (2026-08-09): a `type: 'modal'` target names a page,
only; the object fallback is consumer leniency and enters retirement.
- objectstack-ai#20367, ruling B: one authoring shape; `defineStack` and
`composeStacks` stamp a provenance mark and `composeStacks` refuses an
unmarked input with `STACK_PROVENANCE_MISSING` / 422.
- objectstack-ai#7456, Option A: the embedded `objectName` gets the registered rule's
existence check, and only a dangling name newly refuses.
- objectstack-ai#19926, ruling A: `packages: null` is malformed, not absent.
- objectstack-ai#19784, ruling C: a non-array concat key is refused with the envelope.
- objectstack-ai#5005 (2026-08-04): same value passes, a conflict throws a
prescriptive error, an undeclared top-level key warns.
- objectstack-ai#8687, Shape B: the top-level stack door is strict, and the lint
yields to it.
- objectstack-ai#14122 / objectstack-ai#18202: one artifact carries N packages; the artifact-scoped
permission and seed references resolve against the artifact, a stack
that does not opt in is untouched.
- objectstack-ai#17518, ruling A prime: the artifact-stage and record-stage package
bodies are declared beside the assembled one.
- objectstack-ai#6889 and objectstack-ai#7397: inline page-element actions and object-embedded
actions get the registered action rule's target cross-references.
- objectstack-ai#17063: the `type: 'page'` list-view mount is retired. objectstack-ai#17556: an app
declares its own `devHint` / `devLogins`. objectstack-ai#17614 and objectstack-ai#18056: the
no-`en-US`-floor report and its declared scope. objectstack-ai#14552 and objectstack-ai#15963: every
`defineStack` refusal carries an ADR-0112 envelope; objectstack-ai#16449 registers
each shipped code in the ledger. objectstack-ai#3265 and objectstack-ai#3308: `requires` is
validated at the producer and the camelCase aliases are removed. objectstack-ai#14153
and objectstack-ai#20332: trigger capability validation and its `triggers` without
`automation` pair. objectstack-ai#20646: the root entry stops carrying the migration
registries. objectstack-ai#2611: named import mappings are wired into the import path.

## Readers

173 needles (each old literal, a 24-character window around each id, and
the text left and right of each id) were searched with `git grep -F`
over the tracked tree at the base outside the 15 files, with a lit
control (`composeStacks` in `stack.zod.ts`, hit) and a dark control (a
nonsense string, no hit). One needle hit, one line: a `//` comment in
`packages/qa/dogfood/test/fixtures/override-composite-fixture.ts` that
quotes the tail of one title with its id. It is a comment, not a reader.
The only reader of the declared label is the file's own
`describe(row.label, ...)`. No group file reads a test name
(`currentTestName`, `expect.getState`, snapshots: 0). The 15 file names
appear elsewhere only in prose and comment lines, in
`packages/spec/test-typecheck-debt.json` (keyed by file and error text,
not by title) and in two `dispatch-gates.mjs` /
`check-changeset-no-major.mjs` fixtures that name a path or a changeset
file. Every old literal occurs exactly once across the 15 files (its own
site).

## Text-only proof

- `textonly28.cjs` (stage 28's, md5 `957eff6b3837d762b8e03d070155930a`),
per file, base text against head text: both texts with every string
token masked are byte-equal (code and comments), the string-token counts
are equal, and every changed token sits in a describe / it / test title
position or on a declared line, carried an id at base and carries no `#`
plus digits at head. 15 / 15 files SAME; 57 changed tokens, 56 titles
and 1 declared label (run with `--declared 91`); per-file counts exactly
as predicted in writing at 2026-10-08T22:57Z, before the edit. The 3
untouched files read SAME with 0 changed.
- 17 / 17 controls on scratch copies of the head files behave as
predicted: an identifier rename, a numeric literal, a comment edit, a
non-title string given an id, a rewritten title given a new id, a title
that was id-free at base edited, one rewritten title reverted to base
(SAME, 0 changed), an it.each title given an id, an expect message
changed, a title re-split into a plus chain, a template title given an
id, a test added, an id appended to a rewritten title in a many-title
file, an untouched file (SAME, 0 changed), the declared label run
without `--declared` (refused), the declared line plus another changed
string (refused) and the declared line alone (SAME, 2 changed).
- `git diff -U0`: 57 plus and 57 minus lines, each the planned line; 0
added lines carry `#` plus digits; control-byte scan of the 12 files and
of this body: 0 hits.

## Tests

- The 15 files with `--project local --project repo` and the JSON
reporter, at the base (same worktree, before the edit) and at the head
`65b42b7d1`: 405 / 405 passed in 15 files, 108 suites each side;
per-file test count and status sequence identical in 15 / 15; 229 full
names changed, 0 mismatches against the plan (each head name equals the
base name with the planned replacement applied); 0 duplicate full names
either side; names with `#` plus digits 229 at base, 0 at head.
- spec `vitest run --project local`: 626 files passed, 18740 tests
passed | 1 todo, exit 0.
- spec `typecheck`: `VERDICT command-exit 0`; `check:test-typecheck` 52
files / 246 errors / 135 pinned signatures held, unchanged; `tsc
--listFilesOnly -p tsconfig.test.json` holds 15 of 15 group files.
- turbo build of all packages before the gates: `Tasks: 71 successful,
71 total`. Heavy runs went through `scripts/pm/os-verify-lock.sh` (slot
`dev-20749-s29`).
- No reverse verification or ablation applies: no assertion, gate or
reader moved.

## Gates

All at `65b42b7d1`. The dispatch list (77 commands, derived at
`3f80f1716`) equals the list re-derived from the real change set at this
head, 12 paths against merge base `43fc50051` (114 changed lines):
nothing added, nothing dropped. All 77 exit 0 and `dispatch-gates --ran`
reads "77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN". Also run, exit 0:
the five roster families that share a directory with the diff
(`check:meta-url-spelling`, `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) and `check:generated` ("All 15 generated
artifacts are up to date", measured against the dist the full build
wrote at this head). ESLint, narrowed and proven: 15 files linted with
`--no-inline-config`, 0 errors, 0 warnings; the population read from
ESLint's own config is 15 configured, 0 ignored; no file has
`parserOptions.project` or `projectService`, so type-aware linting is
not enabled and this diff cannot move the verdict of a file it did not
touch.

## Packaging and landing

- No changeset: `skip-changeset`. `npm pack --dry-run --json
--ignore-scripts` in `packages/spec` lists 2069 files, none of the 12
changed files and no `*.test.ts`; controls `src/stack.zod.ts`,
`dist/index.mjs` and `package.json` are present; an old and a new title
phrase read 0 files in `dist/`, the control `Unrecognized key` reads 42.
- Not governed: `check-governed-merges --test` on the 12 paths reads 0
of 12; 114 changed lines.
- `git merge-tree` onto `origin/main` `6a53564b9`: clean. None of the 17
open PRs touches any of the 15 files (file lists read; 6 of them touch
`packages/spec/src`, so the scan sees spec files).

## Acceptance notes

- Class (e) is not finished: 16 messages / 17 ids remain in 12 files,
all in subdirectories. By directory: `ui/` 9 / 9 in 7 files,
`automation/` 2 / 3 in 1, `ai/` 2 / 2 in 1, and `api/`, `contracts/` and
`kernel/` 1 / 1 in 1 each. Their plan is the cutting seat's: stage 27's
landing sets the needles stage apart (one stage, with an at-tier review)
and keeps `ui/`'s four colour literals as decided, and this stage
measured neither.
- Regrowth for stage 30's plan: five subdirectory test files carry 6
messages / 7 ids that were added after stage 27's landing. The files are
older; the ids entered their strings in three commits.
`api/meta-item-response-shapes.test.ts` 1 / 1 (a title, from objectstack-ai#22126);
`automation/builtin-node-config.test.ts` 2 / 3 (two titles, from objectstack-ai#22259,
which re-added ids to a title stage 10 had rewritten);
`kernel/manifest.test.ts` 1 / 1, `ui/action-description.test.ts` 1 / 1
(titles) and `ui/view-submit-redirect-url.test.ts` 1 / 1 (an `other`
string, to be needle-checked there), all three from objectstack-ai#22125. Nothing
regrew since stage 28's landing: the per-file census at `b7e01fbbd`
equals the one at `43fc50051`. Test files are outside
`check:doc-authoring`'s ledgered leg, so nothing refused these on
arrival. Not edited here.
- Code comments in the 15 files still carry live ids (55 lines).
Comments are not this card's share. Carrier: none.
- `origin/main` moved from `43fc50051` to `6a53564b9` while this ran;
one commit touches `packages/spec` (`api/error-code-ledger.zod.ts`, none
of the 15 files), so nothing was merged into the branch.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…n in words instead of a tracker number (stage 30) (objectstack-ai#22414)

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

Stage 30 of this card: the class (e) remainder, the test strings shipped
under the `packages/spec/src` subdirectories, as ruled in `5902360492`
on objectstack-ai#20513. The census at the base reads 17 messages / 18 ids in 12
files. This stage rewrites 7 of them (6 titles and 1 expect message, 8
ids) in 5 files: each now states what its record decided, or drops the
number where the title already says it. The other 10 messages / 10 ids
stay, 4 because earlier stages decided they are not citations and 6
because an assertion matches the string against text this claim does not
let the stage edit; both groups are named under "What stays". Text only:
no assertion, identifier, test count, code comment, file name or
non-test file changes. No file is deferred.

## Census (re-taken first)

The instrument is stage 28's `census28.cjs`, byte-identical (md5
`31d8488b5194b8d3048e3fcaec0efaed`, the value stages 28 and 29
published): an AST walk over the `packages/spec/src` test files, one
message per folded string (a lone literal, a template, or a plus chain)
that matches the gate's id pattern, a title when the folded root is
argument 0 of a describe / it / test / suite / bench call, comments
never read. It was run against the three published readings before it
was trusted, and all three reproduce exactly: 128 messages / 130 ids in
37 files at `f7b8a5932b`, 191 / 200 in 53 files at `aa09db58c9`, 73 / 76
in 24 files at `b7e01fbbd`.

| reading | messages / ids | files |
|:--|--:|--:|
| base `11d119ab1` | 17 / 18 (titles 6 / 7, other 11 / 11) | 12 |
| stage 29's landing `0ef9029da` | 17 / 18, per file equal to the base |
12 |
| this head | 10 / 10 (titles 0 / 0, other 10 / 10) | 7 |
| the 5 edited files, this head | 0 / 0 | 0 of 5 |

Stage 29's ACCEPT carried 16 / 17 (ui 9 / 9 in 7 files, automation 2 /
3, ai 2 / 2, api 1 / 1, contracts 1 / 1, kernel 1 / 1). The base reads 1
/ 1 more, all in `ui`: the title `carries no ruling date and no tracker
id (objectstack-ai#22093)` at `view-submit-redirect-url.test.ts:341`, which PR objectstack-ai#22322
(`ad381fd94`, landed 2026-10-09T00:35Z) added after stage 29's head. So
`ui` reads 10 / 10 in 7 files, and nothing else moved. The census at
`origin/main` `e75dceddd` (8 commits past the base, none touching the 12
files) reads the same 17 / 18 in the same 12 files, so nothing regrew
while this stage ran. The reading is within one message of the claim's,
so there was no re-cut.

Per file, messages at base: `ai/build-progress` 2,
`api/meta-item-response-shapes` 1, `automation/builtin-node-config` 2 (3
ids), `contracts/approval-service` 1, `kernel/manifest` 1,
`ui/action-description` 1, `ui/component-props-unknown-members.pin` 1,
`ui/dashboard-chart-structure-refusal` 2, `ui/dashboard` 2,
`ui/notification` 1, `ui/strictness-batch14` 1,
`ui/view-submit-redirect-url` 2.

Controls:
- Pathspec: the 12 named paths hit the control word `describe(` in 12 of
12 files and a nonsense word in none; the census scanned 12 of 12.
- Planted, in a scratch tree: an id in a describe title, a plus-chain
title, an expect message, a template literal, a cross-repo spelling and
a ledger-style string each read once (6 / 6); a comment, a six-digit
colour, an HTML entity, a two-digit number and a hex colour with a
letter read 0.
- Lit and dark inside the group: the 5 edited files read 1, 2, 1, 1 and
2 messages at base and 0 at the head; the 7 untouched files read the
same at both ends.

## Deferral

At the census (2026-10-09T03:03Z) 17 PRs were open; at the re-scan
before opening this PR (04:13Z), 13. Every file list was read through
REST (605 and 593 rows). None touches any of the 12 files: lit control
`api/protocol.test.ts` (PR objectstack-ai#22323) found, dark control 0. Of the four
PRs the claim named, objectstack-ai#22380 has landed and objectstack-ai#22315, objectstack-ai#22323 and objectstack-ai#22215 are
open; none of them touches a file in this group. Deferred files: none.

## What changed

7 literals, one line each, in 5 files: +7 / -7. Every file keeps its
line count.
- `api/meta-item-response-shapes.test.ts:226`: the `[objectstack-ai#22114] ` prefix
goes; the title already says what objectstack-ai#22126 landed, that the read serves
the version token and the 409 carries the current one as data.
- `automation/builtin-node-config.test.ts:434`: "a CEL envelope beside
literals; the `{token}` dialect retired". objectstack-ai#14149's ruling A made an
assignment value a CEL envelope beside literals, and objectstack-ai#19939 retires the
`{token}` dialect in flow value slots; the title already stated both, so
only the two numbers go.
- `automation/builtin-node-config.test.ts:485`: the `[objectstack-ai#19939] ` prefix
goes from the REFUSES title.
- `kernel/manifest.test.ts:681` and `ui/action-description.test.ts:235`:
the trailing `(objectstack-ai#22093)` goes. objectstack-ai#22093 decided that author-visible help
and refusals carry no service-interface name, ruling date or foreign
example id, and both titles already say what their bodies pin.
- `ui/view-submit-redirect-url.test.ts:341`: the trailing `(objectstack-ai#22093)`
goes from the title.
- `ui/view-submit-redirect-url.test.ts:118`: the expect message `states
the rule, not its ruling date (objectstack-ai#22093)` drops the number. It is an
assertion's failure message, so it was needle-checked first (below) and
is the one declared non-title string.

All seven are "drop a number the title already explains". None needed a
rewrite in new words, because each title already carried the decision.

## What stays, and why

10 messages / 10 ids in 7 files, none edited.

Four are CSS hex colours, not citations. `colors: ['objectstack-ai#111', 'objectstack-ai#222']` at
`ui/dashboard-chart-structure-refusal.test.ts:94` and `palette: ['objectstack-ai#111',
'objectstack-ai#222']` at `ui/dashboard.test.ts:124` are fixture input the schema
under test reads. Stage 21's ACCEPT (`6001279159`, decision A) kept them
by file and line, and every later stage carried them forward.

Six are strings that an assertion matches against text outside this
stage's edit surface. Moving one at the same strength means editing a
non-test source docblock (and, for the first two, its generated
reference page) or the assertion that matches it. The claim forbids both
and says to stop and report, so none is touched; `open_questions` in the
report carries the decision.
- `ai/build-progress.test.ts:236` `'cloud#2172'` and `:237`
`'objectui#7388 block 2'`: `toContain` over the source text of
`ai/build-progress.zod.ts` (docblock lines 8, 27 and 85), which
`content/docs/references/ai/build-progress.mdx` renders.
- `contracts/approval-service.test.ts:274` `'objectstack-ai#16495'`: `toContain` over
the docblock above `continueRestoredRun` in
`contracts/approval-service.ts` (line 999).
- `ui/notification.test.ts:123` `'// [objectstack-ai#4610]'`: the locator of the
tombstone note in `ui/notification.zod.ts:94`; the file's own
`toMatch(/^\[objectstack-ai#4610\]/)` at `:134` reads the same note.
- `ui/strictness-batch14.test.ts:395` `'objectstack-ai#5015'`: `toContain` over
`ui/notification.zod.ts` and `ui/sharing.zod.ts`.
- `ui/component-props-unknown-members.pin.test.ts:322` `ruling:
'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'`: the
file's own assertion at `:417` matches the value with `/objectstack-ai#21704/`. Stage
20's ACCEPT (`5998488373`) kept it for this reason and sent it to the
needles' stage.

Readers of the seven rewritten strings: none. `git grep -F` at HEAD over
the tracked tree outside the 12 files, with the full literal, a
24-character window around each id, and the text on each side of each id
(29 needles over all 17 sites): the only hits are the readers of the
kept strings named above, the lit control (`composeStacks` in
`stack.zod.ts`) hits and the dark control does not. The same needles
searched inside the 12 files, outside each literal's own span: the only
hits are two code comments beside `:322`. The five short needles
(`cloud#2172`, `objectstack-ai#16495`, `// [objectstack-ai#4610]`, `objectstack-ai#5015`, `objectstack-ai#21704`) fall under the
script's 12-character floor, so their readers were confirmed by direct
`git grep -F` with a dark control.

## Cited records

Read with their comments as the API serves them: objectstack-ai#22114 (8 of 8
comments; landed as PR objectstack-ai#22126), objectstack-ai#14149 (12 of 12; ruling A `5507504961`,
landed as PR objectstack-ai#15113), objectstack-ai#19939 (15 of 15; pass 1 landed as PR objectstack-ai#22259, the
card stays open), objectstack-ai#22093 (17 of 17; PRs objectstack-ai#22125, objectstack-ai#22309 and objectstack-ai#22322), and
the four PRs themselves. objectstack-ai#19939 is still open: its pass 1 refuses the
`{token}` dialect in flow value slots and keeps two spellings (the date
macros and `{$User.*}`) until CEL can write them. The describe's
PRESERVATION test still accepts those two, and the title keeps the
record's own verb, "retired", as PR objectstack-ai#22259 wrote it. The title and the
test body say the same thing the record says.

## Verification

At head `8885dbf1c` (one commit on base `11d119ab1`):
- **Text only.** `textonly28.cjs` (stage 28's, md5
`957eff6b3837d762b8e03d070155930a`) on all 12 base copies against their
heads: 12 / 12 SAME. 7 changed tokens, as predicted in writing at
2026-10-09T03:08Z before any edit or test run: 6 titles and 1 declared
string (`--declared 118`). Every other string token, identifier, number,
punctuation mark and comment is byte-equal. 16 controls, expectations
written in the script before the first run, 16 / 16 as predicted: an
identifier rename, a numeric literal, a comment edit, an undeclared
expect message, a rewritten title given a new id, an id-free title
edited, one title reverted to base (SAME, 0 changed), a declared label
without `--declared`, a declared line plus another changed string, the
declared line alone (SAME, 2 changed), a title re-split into a plus
chain, a test added, an untouched file (SAME, 0 changed), an id appended
to a rewritten title, a kept needle rewritten, a kept hex colour
rewritten. The two controls that mutate a string beside the declared
line fail at the mutated line, not at 118.
- **Tests, 12 files, base and head.** `--project local --project repo`
with the JSON reporter, 618 tests in 88 suites each side, all passed.
Per-file test count and status sequence identical in 12 / 12. 23 full
names changed (4 + 14 + 1 + 3 + 1), 0 mismatches against the plan. Names
carrying `#` plus digits: 23 at base, 0 at head. Duplicate full names: 3
and 3, the same three `[object Object]` it.each rows at both ends.
- **Full spec unit tier at the head**, under the verify lock: `Test
Files 626 passed (626)`, `Tests 18743 passed | 1 todo (18744)`.
- **Build and typecheck**, under the verify lock: `turbo run build` over
`packages/*` and `packages/*/*`, `Tasks: 71 successful, 71 total`;
`@objectstack/spec` `typecheck` exit 0 with `check:test-typecheck`
holding 52 files / 246 errors / 135 pinned signatures, the same figures
as stage 29; the 12 files are all in the `tsconfig.test.json` program.
- **Gates.** `dispatch-gates.mjs --commands` at the head derives 79
(stage 29's 77 plus `check:authorable-surface` and
`check:yaml-examples`); all 79 exit 0, and `--ran` reconciles 79
derived, 79 run, 0 NOT-MEASURED. The five artifact-roster families that
keep their roster in a directory one of the paths is in
(`check:meta-url-spelling`, `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) and `check:generated` (all 15 artifacts up
to date) also exit 0.
- **ESLint**, `--no-inline-config`, 12 files: 0 errors, 0 warnings.
Population from ESLint's own config: 12 configured, 0 ignored, 0 with a
type-aware parser option, so this diff cannot move the verdict of a file
it does not touch.
- **Skip-changeset.** `npm pack --dry-run --json --ignore-scripts` in
`packages/spec`: 2069 files, 0 `*.test.ts`, 0 of the 5 edited files;
controls `src/stack.zod.ts`, `dist/index.mjs` and `package.json`
present. The rewritten expect message occurs in 0 files of `dist/`; the
control `Unrecognized key` occurs in 42. Nothing published changes.
- **Governed.** `check-governed-merges.mjs --test` on the 5 paths: 0 of
5, not governed; 14 changed lines.
- **Merge.** `git merge-tree --write-tree` onto `origin/main`
`e75dceddd`: clean.
- **Bytes.** 0 added lines carry `#` plus digits; 0 control bytes in the
changed files.

Declared narrowing: the 12-file base and head comparison ran outside
`os-verify-lock.sh`, after three queue turns (about 28 minutes) ended
without a grant. It is a 12-file run with two workers; the workspace
build, the typecheck and the full unit tier all ran under the lock. The
gates are `check:*` runs, which do not use the lock.

## Acceptance notes

- **Regrowth continues.** Since stage 27's landing, four PRs (objectstack-ai#22125,
objectstack-ai#22126, objectstack-ai#22259 and objectstack-ai#22322) added 7 messages / 8 ids to test strings in
files that already existed, one of them to a title objectstack-ai#22322 wrote while
stripping a ruling date from a describe. Test files sit outside
`check:doc-authoring`'s ledgered leg, and the ruling adds no gate, so
the per-stage census is the only instrument. An observation about the
burn-down's denominator, not a class a / b / c finding.
- **Comments are untouched.** Code comments in these files still cite
ids (for example `// ─── assignment (objectstack-ai#14149) ───` at
`builtin-node-config.test.ts:432`); comments are objectstack-ai#20234's share.

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

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/l tests tooling

Projects

None yet

2 participants