Skip to content

fix(metadata-protocol): the default /diff range labels its to side with the active row's own version - #20443

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20397-meta-diff-default-labels
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20397-meta-diff-default-labels

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20397

Clause-②: no

What changes

diffMetaItem (packages/metadata-protocol/src/protocol.ts), its default range only. With no toVersion the to side is the current active sys_metadata row. Its body was compared, but toVersion came from the newest sys_metadata_history row. That row is a draft save whenever a draft is pending, because every draft save appends a history row too. So the labels and the bodies named different rows.

Now one read of the active row supplies both facts: the body compared, and the row's own version as the label. SysMetadataRepository.put stamps that column in the same transaction that appends the history row carrying the same body. The fromVersion default is unchanged: the history version immediately before the label. With no active row, the to side is absent, so toVersion is null, as DiffMetaItemResponseSchema declares for an absent side.

The hunk also deletes the two locals the old read used (repo, fullRef; nothing else in the function read them), and adds one clause to the method's docblock. Nothing else in protocol.ts moves. rest-server.ts, packages/spec and sys_metadata_history are untouched. The response shape is unchanged: @objectstack/metadata-protocol patch changeset.

Measured before and after, on the real REST stack

Real routes and a real ObjectStackProtocolImplementation over better-sqlite3 :memory: with the real sys_metadata* objects. Each read is GET /meta/:type/:name/diff with no from / to, as an author (manage_metadata). "Before" is origin/main dbddf02c1 (the dist carried the old label line). "After" is 53cad078f. The probe was a scratch file, deleted and never committed.

lineage (v = history version) before after
app: v1 active, v2 draft, v3 draft (the card) 2 → 3, label changed "Atlas v2 draft" → "Atlas v1": the to side is the v1 body null → 1, the v1 body as added
view: v1 active, v2 draft (the card) "no changes", labelled 1 → 2 null → 1, the v1 body as added
view: v1 active, v2 active, v3 draft 2 → 3, no changes 1 → 2, label A → B, byte-equal to ?from=1&to=2
app: the same lineage 2 → 3, no changes 1 → 2, byte-equal to ?from=1&to=2
view: v1 draft, v2 draft (never published) 1 → 2, every v1 key under removed (a draft body served as the from side) null → null, empty
view: v1 draft only null → 1, empty null → null, empty
app: draft only 404 (the REST handler's absence for a gated type) 404, unchanged
view: v1, v2 active, then deleted (v3 tombstone) 2 → 3, every v2 key under removed null → null, empty (see Acceptance notes)
view: deleted, then a draft save v4 3 → 4, no changes null → null, empty
view: v1 active, v2 draft, v3 publish (± a pending v4 draft) 2 → 3 no changes (3 → 4 with v4 pending) 2 → 3 no changes in both cases (see Acceptance notes)

The order's mechanism hypotheses

  • H1, confirmed. It reproduced as the table's first two rows. Source: the request.toVersion === undefined arm read the body through repo.get(..., { state: 'active' }) and the label from histRows[histRows.length - 1].version.
  • H2, confirmed at the row, not at the projection. The sys_metadata row carries version, equal to the history row whose body it is. Measured in every lineage above: active v1 = history v1, active v2 = history v2, the published row v3 = the publish history row v3. The MetadataItem that repo.get returns does not carry it. rowToItem builds ref from fullRef (no version) and exposes only the content hash. So diffMetaItem reads the row itself, with the same predicate repo.get uses (active state, no package scope). The same file has two precedents: the ADR-0067 commit capture in publishPackageDrafts reads the raw active row's version as prevVersion, and resolveOverlayPackageBinding reads the raw row rather than widening MetadataItem. Nothing looks a version up by body or hash.
  • H3, measured. The corrected toVersion does not change the from rule. With v1 active, v2 draft, v3 publish, the default answers 2 → 3 "no changes". The from side is the unpublished draft save v2, whose body is the one v3 published. The answer is the same with a v4 draft pending. The previously published v1 differs and is not the from side. Which history rows count as versions is not changed here. See Acceptance notes.
  • H4, pinned. No active row ⇒ null → null, empty buckets, and no draft body on either side. Before: labelled with the newest draft save, and with two draft saves the first one's body was served as the from side.

Tests

  • packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts: a new #20397 describe block, 5 cases over seeded rows. It sits beside that file's already-pinned engine double, which honours the where on both tables, so the engine-double ledger is untouched. The cases: a pending draft for view and for app equals the explicit range; the card's app reading; draft-only; deleted, with the deletion still readable as 2 → 3.
  • packages/rest/src/meta-diff-default-range-labels.test.ts (new, test side only): 6 cases through the real routes and the real writes. toVersion equals the active row's version, read past every door, and the answer equals ?from=1&to=2 for an app and a view. The two card readings, draft-only, and a lit control with no draft pending, which answers 1 → 2 both before and after.

Ablation, from the committed fix (53cad078f), through scripts/ablation-replace.mjs. The mutation puts the old label line back (anchor 1 → 0, blob 3bb7041da297 → 7e3ce0508bd8). After a @objectstack/metadata-protocol rebuild, ablation-dist-preflight found the marker in 2 built files, exit 0. protocol.diff-dead-history-read.test.ts: 5 failed, 6 passed (the 5 new cases red, the 6 older ones green). meta-diff-default-range-labels.test.ts: 5 failed, 1 passed (the lit control). The restore leg brought the blob back to the HEAD blob with git diff HEAD empty. After a rebuild, --absent found the marker absent from all 24 built files and the tree clean (exit 0). The re-runs gave 11/11 and 6/6. Direction: red, as predicted. (A first run proved the same mutation and the same 5 + 5 reds. Its preflight tree reading refused only because the source spells the marker with a ! that the build drops, so it was re-run with --source-marker.)

Gates, at the measured head fd767fec0 (after a true merge of origin/main)

  • pnpm --filter @objectstack/metadata-protocol exec vitest run: 189 files passed, 3 skipped; 2750 tests passed, 19 skipped. typecheck (tsc --noEmit) exit 0. Its program includes the edited test file (--listFilesOnly: 1).
  • pnpm --filter @objectstack/rest exec vitest run --project local: 215 files passed; 3898 tests passed, 26 skipped. typecheck (tsc --noEmit plus check:test-typecheck, 0 debt) exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 62 commands. All 62 exit 0. --ran reconciliation: 62 derived, 62 run, 0 NOT-MEASURED, a derived zero with every exit code recorded. check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3). They were re-run exit 0 after turbo run build --filter='./packages/*' --filter='./packages/*/*', and the dist-reading gates were re-run on that full build.
  • eslint, narrowed to the 3 touched TypeScript files: --no-inline-config --format json gave 3 results, 0 errors, 0 warnings. All 3 are in eslint's own population (isPathIgnored false for each). This config enables no type-aware linting (no parserOptions.project), so the diff cannot move an untouched file's verdict. The repo-wide pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

… active row's own version

With no toVersion, diffMetaItem compared the active sys_metadata row's body
but labelled it with the newest sys_metadata_history row, which is a draft
save whenever a draft is pending. One read of the active row now supplies
both the body and its version; with no active row both labels are null.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 6 documentable anchor(s).

16 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 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5.

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

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5

⚠️ 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 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs read: card #20397 (body and all four comments: triage 5865097374, claim 5868801238, os-dev-report 5870523105, seat answer 5870555349); PR #20443 body, file list and the net diff against the merge base with main (four files: +451 −12 — one changeset, protocol.ts +38 −12, one edited metadata-protocol test file, one new REST test file); #20378's body, decision box 5864728217 and ruling 5865708652; the source at this head (diffMetaItem, SysMetadataRepository.get / whereFor / rowToItem / put / promoteDraft / delete, getOverlayRepo, DiffMetaItemResponseSchema, the REST /diff handler and its #20156 helpers, the edited test's engine double); the check-runs on the head, read twice (first read: 9 in progress; final read below).

① Derived judgments

  1. Accept set — unchanged, right. diffMetaItem's request (type, name, fromVersion, toVersion, organizationId) and the door's query parsing are untouched. Both explicit arms (request.toVersion !== undefined, request.fromVersion !== undefined) are unmoved in the diff, so an explicit from / to answers exactly as before.
  2. Public surface — unchanged, right. DiffMetaItemResponseSchema (packages/spec/src/api/protocol.zod.ts) is untouched, as is the method's declared return type. The schema's own text, toVersion is "null when that side is absent", is what the no-active-row answer now honours.
  3. The default range, judged from the code at this head:
    • Pending draft (v1, v2 active; v3 draft): a draft save upserts the DRAFT row (put reads whereFor(ref, state)), so the active row keeps version 2. To = 2 with the active row's metadata; from = the history version immediately below 2 = 1. put wrote the active row's body and history row 2 in one transaction with the same body, so the answer is byte-equal to ?from=1&to=2. Right; pinned in both test files (the REST file also proves the fixture: active version 2 while history is [1, 2, 3]).
    • No draft (v1, v2 active): to = 2, from = 1 — the same answer as before, because the newest history row IS the active row there. Right; the REST control pins it.
    • Draft-only (no active row): to = null; the from default runs only when toVersion !== null, so from = null; both bodies fold to {} and the buckets are empty. Before, the label was the newest draft save and, with two draft saves, the earlier one's body was served as the from side. Right, and it takes a draft body out of a default answer. For app / doc / book the REST handler answers absent before reaching the protocol (gatesPerCaller + fetchCurrentMetaDocument null); view reaches it, as the PR table says.
    • Deleted item: the repository's delete removes the sys_metadata row and appends a metadata: null tombstone history row, so to = null and the default answers null → null, empty. Before: N-1 → N with every key removed. This is a caller-visible change beyond the labels. It is disclosed in the changeset (second bullet) and the PR body (Acceptance notes); it is the docblock's rule (no active body ⇒ absent side) and the schema's text; the old answer matched the schema only because a tombstone's body is null like the absent active body; the deletion stays one explicit range away (?from=N-1&to=N, pinned). Zero in-repo consumers of the default range. Right.
    • Explicit from / to: untouched. Right.
  4. Route A (seat answer 5870555349) — the read is the same row, right. repo.get(fullRef, { state: 'active' }) runs findOne('sys_metadata', { where: whereFor(ref, 'active', undefined) }) = { type: ref.type, name: ref.name, organization_id: this.organizationId, state: 'active' }, no package_id (the opts carry no packageId key), on the same engine instance (getOverlayRepo hands this.engine), with organizationId = the same orgId = request.organizationId ?? null. The old fullRef.org = orgId ?? 'env' was only the projection's label (rowToItem → ref.org), never a predicate. The new where { organization_id: orgId, type: singularType, name: request.name, state: 'active' } has the identical key set and values (singularType was fullRef.type). Org partition: strict, and the same one the history read uses, so both sides of the diff stay in one lineage. Package scope: both reads are unscoped ("match any package"); the publishPackageDrafts can promote (and drain) ANOTHER package's draft row — the promote resolves the draft without the ADR-0048 package dimension #8907 two-package ambiguity is shared and unchanged. Body: rowToItem parses metadata the same way; its {} for a null-metadata row and the new null both fold to {} at rawTo = toBody ?? {}. The one step skipped is assertOpen(), a lifecycle guard; protocol.ts never calls close() on an overlay repository, so it is nil.
  5. Nothing else caller-visible moved, right. Redaction: the diff-raw / redact-emitted block after the range is untouched and still keys on singularType. The [finding] diffMetaItem answers 200 with an empty diff when sys_metadata_history is unreadable — an outage is indistinguishable from "nothing changed" #8833 outage path: the sys_metadata_history try/catch runs first, in the same order as before; the active-row read had no catch before either (repo.get propagated raw). Canonical type folding: canonicalizeMetaRequestType at the top is untouched, and the new where uses the folded singularType exactly as the old fullRef.type did. The REST handler's [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156 rebuild (diffSides, diffEmittedFrom) reads only the three buckets, never the labels, so the label change moves nothing there.
  6. Dialect guard, not a defect. toVersion is taken only when typeof current.version === 'number'. Both sys_metadata.version and sys_metadata_history.version are Field.number; the explicit-range path already keys byVersion by the history row's numeric version, and publishPackageDrafts uses the identical guard on the same column; Temporal Conformance (live PG + MySQL) is success on this head.
  7. Tests, right. The edited file's engine double honours where on find and findOne (scalar equality; the seeds' organization_id: null match the null predicate) and asserts the findOne predicate through assertEngineFindOnePredicate; no new double is introduced. The six older #8798 cases all pass explicit from / to, so the default-range change cannot touch them. The new REST file boots real routes over better-sqlite3 with an author caller and asserts toVersion equals the stored active row's version.
  8. [finding] GET /meta/:type/:name/diff serves PENDING draft content to a member with no authoring capability: its history versions include draft saves, and it is the one draft-serving door the #20338 gate leaves open #20378's surface untouched, right. The merge-base-to-head diff on packages/rest/src/rest-server.ts, packages/spec and packages/metadata-core is empty; the /diff and /history handlers are unchanged; no column on sys_metadata_history; which history rows count as versions is unchanged. (PR fix(rest): /meta/:type/:name/diff and /history are authoring doors, refused as /meta/_drafts refuses (#20378) #20440 is still open and unmerged; origin/main has since advanced past this head's merge base on rest-server.ts, with no overlap with this PR's four files.)
  9. H3, judged. After v1 active, v2 draft, v3 publish, promoteDraft runs put with state: 'active', so the active row is stamped 3 and history row 3 carries the draft's body; the default answers 2 → 3, "no changes", with or without a v4 draft pending. Before this PR the same lineage answered 2 → 3 when no draft was pending (and 3 → 4 with one). So the from side is pre-existing and the documented rule ("the immediately previous history row"); this PR only corrects the to label. Changing it means deciding which history rows count as versions, which claim 5868801238 forbids here and ruling 5865708652 declined the state column for. Not a defect in this PR's scope. Escalated in ③.

② Semver level

  • Changeset: @objectstack/metadata-protocol: patch. The diff publishes one behaviour fix inside an existing method of a released package; the packages/rest change is a test file only, which publishes nothing, so no second changeset is owed and skip-changeset does not apply. Check Changeset is success. Right.
  • Clause-②: no, right. The accept set is not widened (no new input is accepted) and no public surface is enlarged (schema, return type, route and query unchanged). No arm is declared, so the pair is well-formed; patch with no is consistent with AGENTS.md rule 3 (yes would take at least minor).
  • Changeset sentences: the to side is the active row and its body was compared while toVersion came from the newest history row — TRUE (the old line histRows[histRows.length - 1].version). Every draft save appends a history row — TRUE (put appends for every state). The two measured readings (2 → 3 over the v1 body; "no changes" labelled 1 → 2) — TRUE by the old mechanism; the measurement itself is not re-run. toVersion is now the active row's own version, read in the same read as its body; the fromVersion default is still the history version immediately before it; version 2 with a draft pending answers 1 → 2, the same as ?from=1&to=2 — TRUE. No active row ⇒ both labels null, empty buckets; before, a draft-only item was labelled with its newest draft save and its from side could be an earlier draft save's body; a deleted item was labelled N-1 → N; the deletion is still read by naming its versions — all TRUE. "Unchanged: the response shape, explicit from / to ranges, and the default range of an item with no draft pending" — TRUE as qualified by the bullet above it (a deleted item has no draft pending and its default range did change); read alone the last clause is imprecise. Wording nit, not a false claim.
  • PR-body sentences: the mechanism (H1), the row-versus-projection reading (H2: rowToItem exposes ref, body, hash and no version; put stamps version in the same transaction as the history row), the two in-file precedents, the from rule unchanged, null for no active row, the two locals deleted with nothing else in the function reading them, one docblock clause added, nothing else in protocol.ts moving, rest-server.ts / packages/spec / sys_metadata_history untouched, the response shape unchanged, 5 + 6 new cases, the older six green by construction, the REST handler's 404 for a draft-only app, H3 as the documented rule, ruling 5865708652 taking B and declining A, the stale get docblock mention — all TRUE against the code and the threads. The before/after table, the ablation and the local gate runs are consistent with the mechanism and are not re-measured here (read-only); the head's check-runs stand as the gate verdicts.

③ Boundary flags

  • open_questions[0] — H2's stop clause, A or B. The seat answered A (5870555349). Reviewer: A is right — ① item 4 shows the direct read names the same row under the same predicate, and it changes no shared contract. Answered.
  • Deviation: H2 read at the row, the run did not stop. The sys_metadata row carries version (the column put stamps), so the order's stop condition did not obtain; the projection's lack of it is a MetadataItem fact, not a row fact. Answered.
  • Deviation: the order called [finding] GET /meta/:type/:name/diff serves PENDING draft content to a member with no authoring capability: its history versions include draft saves, and it is the one draft-serving door the #20338 gate leaves open #20378 an open decision. Ruling 5865708652 (letter B, 07:48Z) precedes claim 5868801238 (11:17Z). The dev's reading is TRUE. Answered.
  • Deviation: two unused locals removed, one docblock clause. Confirmed that nothing else in diffMetaItem read repo or fullRef; both inside the claimed hunk; accepted by the seat. Answered.
  • Deviations: PREREQUISITE NOT MET reruns; no labels written. Local-run and labelling matters; the check-runs on the head are the verdicts. Nothing to escalate.
  • H3 — escalated to the seat, as its answer said it would decide after this review. Judgment: pre-existing, the documented rule, outside this PR's scope; no card holds it after ruling B. Recommendation: file it bare for triage rather than leave it as a note. A default answer of "no changes" after a draft-then-publish is a wrong-shaped answer at a public door for the one caller /diff keeps (the same class this card was graded p2 for), and the remedy needs a decision on which history rows count as versions — a triage question, not one this claim may pre-empt. Do not fold it into this PR.
  • Out-of-scope: the stale caller mention in SysMetadataRepository.get's docblock. Right to leave — sys-metadata-repository.ts is outside the claimed surface and the bullet's reasoning still holds. A one-line fix rides the next hunk on that file. Carrier none.
  • Reviewer flags: (i) the deleted-item default range is a caller-visible change beyond the labels — disclosed in both prose surfaces, judged right in ① item 3; (ii) the changeset's last bullet — wording nit, ② above; (iii) the typeof dialect guard — not a defect, ① item 6; (iv) assertOpen() skipped — nil, ① item 4. None blocks.
  • Check-runs on the head, final read: 34 check-runs, 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, 0 in progress. Success includes Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Test Core and all six shards, Type Check (workspace, source gates, consumer gates, debt ledger), Build Core, Dogfood Regression Gate and all three shards, Dogfood Verify CLI, and the four PR-shape guards. Commit status: Vercel success; combined state success.

Implemented-by: claude/issue-20397-meta-diff-default-labels
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 13:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 8cdbe0c Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20397-meta-diff-default-labels branch September 28, 2026 13:51
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…rlier version whose body differs (objectstack-ai#20451) (objectstack-ai#20518)

Fixes objectstack-ai#20451

Clause-②: no

## What changes

`diffMetaItem` (`packages/metadata-protocol/src/protocol.ts`), the
default `from` side only. With no `fromVersion`, the from side is now
the **nearest earlier history row whose body differs from the to
side's**, by the diff's own equality: `diffShallow`'s three buckets not
all empty, with an absent body compared as `{}` (the same `?? {}` the
comparison below it uses). A body-less row (a delete's tombstone)
therefore differs from any non-empty to side, and the walk stops on it
(triage's answer A, 5875579209). With no earlier row that differs, the
from side is absent: `fromVersion: null`, everything added.

- The walk reads only the rows the function already has: the one `find`
over `sys_metadata_history` (no limit), which the function sorts by
`version` in memory. It adds no read and no cap. A unit pin asserts one
history read on a walk path.
- It reads no `operation_type` and adds no state column (ruling B on
objectstack-ai#20378, 5865708652).
- An explicit `?from=` / `?to=` names exactly its versions. The to-side
default (the active row's own version, PR objectstack-ai#20443) is untouched. An
explicit `?to=` with no `?from=` walks back from the named version's
body.
- The comparison runs on the stored bodies before redaction, as the diff
itself does, so a credential-only change still stops the walk and its
values are still not served (unit pin).

The four statements of the default rule now say the new rule, each in
its own words: the `diffMetaItem` docblock,
`DiffMetaItemResponseSchema`'s JSDoc (`packages/spec`), the route's
OpenAPI summary in `rest-server.ts`, and the SDK's `diffItem` docblock
(`packages/client`). Comment and summary text only: no schema, route or
signature change.

## Measured on the real REST stack

The REST pins
(`packages/rest/src/meta-diff-default-range-labels.test.ts`, real routes
and real writes over better-sqlite3 `:memory:`) were committed first
(`9b308b12b`) and run against the unchanged source with its closure
built: **3 failed, 9 passed**. After the change: **12 passed**.

| history (fixture-proved by reading `sys_metadata_history`) | before |
after |
|:--|:--|:--|
| v1 `create` A (active), v2 `create` B (draft save), v3 `publish` B |
`2 → 3`, empty | `1 → 3`, `label` and `columns` changed, equal to
`?from=1&to=3` |
| the same, then a v4 draft save | `2 → 3`, empty | `1 → 3` |
| v1 `create` A, v2 `delete` (no body), v3 `create` A2 (draft), v4
`publish` A2 | `3 → 4`, empty | `2 → 4`, everything added, equal to
`?from=2&to=4` |
| v1 `create` A, v2 `delete` (no body), v3 `create` B (active) | `2 →
3`, everything added | the same bytes (green before and after) |
| v1 `create` New (draft), v2 `publish` New | `1 → 2`, empty | `null →
2`, everything added |
| v1 `create` (active) only | `null → 1`, everything added | the same
bytes |
| explicit `?from=2&to=3` over the first lineage | `2 → 3`, empty | the
same bytes |

The unit pins in `protocol.diff-dead-history-read.test.ts` repeat these
lineages over seeded rows beside the file's read-counting double.
**Ablation**, from the committed state: `node
scripts/ablation-replace.mjs` replaced the walk's differ test (`if
(d.added.length || d.removed.length || d.changed.length) {` → `if (true)
{`, which is the old immediately-previous rule), anchor 1 → 0, blob
`a2d2b7686f29` → `dd9cffcbd1f9`; the file ran **6 failed / 14 passed**,
exactly the six walk-dependent pins; restored, blob == HEAD and `git
diff HEAD` empty. No build is involved: the metadata-protocol suite
imports `./index.js` from source.

## A pending release note corrected: needs confirmation (Check Changeset
stays red)

`.changeset/20397-diff-default-range-labels.md` (PR objectstack-ai#20443, not yet
released) said "The default `fromVersion` is still the history version
immediately before that label." This PR makes that sentence false in the
same release, so it now reads: "The default `fromVersion` rule is not
changed by this entry (objectstack-ai#20451, in the same release, then moves it to the
nearest earlier version whose body differs from the to side's)." One
sentence, nothing else in that file.

This is the DELIBERATE CORRECTION class `check-empty-changeset.mjs`
names, so that gate exits 1 locally and **Check Changeset will stay red
on purpose**. Please confirm the correction on this PR. It was outside
the claim's file surface. `skip-changeset` is not applied and must not
be.

## Changeset

`.changeset/20451-diff-default-from-differs.md`:
`@objectstack/metadata-protocol` `patch` and `@objectstack/rest`
`patch`, `Clause-②: no`. The rest line is there because the route's
OpenAPI summary is a runtime string served in the OpenAPI document. The
`packages/spec` JSDoc and the `packages/client` docblock are
comment-only, so they get no line, per the repo's rule that comments do
not publish. All three packages are in one `fixed` group, so versions do
not move differently either way.

## Verification (measured at `1f258bbd5`, after merging `origin/main`
`9449512a3` with a true merge commit)

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 88 commands, all run, each exit code
written to a file before any pipe. **87 exit 0; 1 exit 1**: `node
scripts/check-empty-changeset.mjs --base origin/main`, the release-note
correction above. Also run, outside the derivation: the four roster
gates whose roster sits under a changed path (`check-changeset-fixed`,
`check:meta-url-spelling`, `check:spec-changes`,
`check:error-code-casing`), all exit 0.
- `dispatch-gates --ran`: "88 derived famil(ies) accounted for — 88 run,
0 NOT-MEASURED".
- Tests, each through `os-verify-lock`: metadata-protocol 189 files
passed, 3 skipped (2759 tests); rest `--project local` 219 files passed
(4181 tests); client 50 files passed (641 tests); spec `--project local`
573 files passed (16801 tests).
- Typecheck: metadata-protocol, rest (with `check:test-typecheck`),
client (with `check:test-typecheck`) and spec all exit 0. Both edited
test files are in their tsc programs (`--listFiles`: 1 hit each).
- `pnpm --filter @objectstack/spec check:generated`: all 15 generated
artifacts up to date, against a spec `dist` built from this tree.
- Declared to CI, not run here: the five path-scheduled jobs (Test Core
shards, Temporal Conformance, Dogfood Regression, Dogfood Verify CLI,
Build Core) and the workspace type-check lanes, which `dispatch-gates`
lists as CI's own shell.

## Statements the census named, measured

- **Edited:** the four above, plus the header of
`meta-diff-default-range-labels.test.ts` (this PR adds to that file),
which said the from side is "the history row immediately preceding that
label".
- **Not false, not edited:**
`docs/qa/platform-checklist/areas/studio-authoring.json` lines 346 and
402 ("omit the params for previous-vs-current"). On that probe's
lifecycle (draft save, publish, second draft save, second publish) the
default range now compares the second published revision with the first
(`2 → 4`), which is the comparison those steps describe. Before this PR
it compared the second publish with its own draft save and answered "no
changes".
- **Not false, not edited:** the test title in
`rest-server-query-number-reads.test.ts:300` ("from/to still mean
previous-vs-current (no version members)"). It asserts only that no
version member reaches the verb, which still holds. This PR does not
touch that file.

## Acceptance notes

- The same checklist steps' explicit range `?from=1&to=2` compares the
probe's first draft save (v1) with its own publish (v2). In the
draft-then-publish lifecycle those two rows carry the same body, so that
range answers "no changes", before and after this PR. This is a
checklist wording issue, not a product defect. No card filed; carrier:
none.
- `DiffMetaItemResponseSchema.fromVersion`'s `.describe()` reads "`null`
when that side is absent (e.g. the item had no earlier version)". It is
still true, and now `null` also answers "no earlier version differs". It
was left as is: a `.describe()` edit regenerates spec docs, and the
claim limits `packages/spec` to comment text.
- `.changeset/20139-rest-query-number-census.md` says
"previous-vs-current on `/diff`" about absent parameters keeping their
default. That is still true of the parameter handling. It is somebody
else's pending note and is not touched.
- Test-side deviation: `seedLineage` in
`protocol.diff-dead-history-read.test.ts` moved from inside the objectstack-ai#20397
`describe` to module scope, unchanged, so the objectstack-ai#20451 block shares it
rather than copying it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants