Skip to content

fix(metadata-protocol): fold diffMetaItem's type at the boundary and stop serving a history outage as an empty diff - #8921

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-8868-diff-verb-fold-and-503
Aug 15, 2026
Merged

fix(metadata-protocol): fold diffMetaItem's type at the boundary and stop serving a history outage as an empty diff#8921
hotlong merged 4 commits into
mainfrom
claude/issue-8868-diff-verb-fold-and-503

Conversation

@hotlong

@hotlonghotlong commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes#8868
Fixes#8833

Two independent defects in one method — diffMetaItem in
packages/metadata-protocol/src/protocol.ts — folded into one PR at the
maintainer's suggestion on #8833 (comment 5302933802: "the metadata seat may
sensibly dispatch #8868 + this card as one diffMetaItem PR (fold + 503)"
).
They are kept legible as two separate changes in the diff.

GET /api/v1/meta/:type/:name/diff is a routed live endpoint with a
caller-supplied :type (rest-route-ledger.ts:184).

1. #8868 — the canonical fold

diffMetaItem was the NINTH /meta entry point on this URL family and the last
one still deriving its type key from PLURAL_TO_SINGULAR, the manifest-COLLECTION
map that #7894 moved this boundary off. It now routes through
canonicalizeMetaRequestType — the same one-line replacement #8769
(publishMetaItem) and #8819 (rollbackMetaItem) made. Three things change:

  • the answer. For the four MANIFEST-ABSENT types (field, seed,
    external_catalog, translation — legitimately absent from that map because
    they are not stack collections) a plural spelling stayed plural all the way into
    the sys_metadata_history query, matched no row, and the endpoint answered a
    well-formed empty diff for an item that does have history. Measured:
    PLURAL_TO_SINGULAR['fields'] is undefined while PLURAL_TO_SINGULAR['views']
    is 'view', which is exactly why manifest-present types were never affected.
  • unrecognised spellings. The meta-plural-url-bypass: PUT /meta/fields/<name> walks around the whole two-tier registry gate — 4 registry types have no entry in PLURAL_TO_SINGULAR #7894 boundary refusal never ran on this verb.
    viewes is now refused 400 INVALID_REQUEST naming both accepted spellings.
    The refusal stays narrow: a name reaching for no declared type (fieldz, a
    possible plugin kind) is still served.
  • the echoed type. Now the canonical spelling — i.e. the one the read
    actually used. See "the one sub-question" below.

2. #8833 — the swallowed outage

The history read sat in a try whose catch was empty apart from a comment.
histRows stayed [] and the code below read that never-filled accumulator as a
real answer, so a sys_metadata_history outage was served as a successful 200
with an empty diff — byte-identical to "these two versions are the same", with no
log line either.

Implementing the maintainer ruling as written, not re-adjudicated: the catch
routes through the platform's existing discrimination,
rethrowUnlessMetadataStoreUnprovisioned (called, not modified).

  • a genuinely absent table (minimal deployment) keeps its benign empty answer;
  • every other read failure propagates 503 SERVICE_UNAVAILABLE with the
    driver error as cause. ADR-0110 D3 — a miss and an outage are different facts.

Option B (a historyUnavailable response key) was declined in the ruling and is
not present here.

⚠️This adds loudness where there was none, uniformly. PR #8841 removed the
last path that threw here, so the outage had become silent for every type. The
ruling was made against that post-#8841 state, and I re-verified it on today's
main before building: no historyMetaItem round trip remains in this method.

Both thrown contracts are declared on the method's docblock, stating the
discrimination rather than just the status code — a caller reading the docblock
learns that an unprovisioned history table still answers benignly, which is the
whole point of the ruling.

The one sub-question the card named, answered from precedent

Should the response echo the canonical spelling or the caller's? #8769 and #8819
agree, so no third answer was minted: both reassign request at the top, which
makes every downstream read of request.type report the canonical spelling.
#8819's own header calls reporting the caller's spelling for a row addressed under
the canonical one the defect it was fixing. The two folded siblings that do echo
a type in their response body — saveMetaItem and deleteMetaItem — both
return { type: request.type }after their fold. This PR inherits that
mechanism exactly.

Verification — full union at HEAD f9811727d

Gate union re-derived from the actual changed paths via
node scripts/pm/dispatch-gates.mjs, then run:

gateresult
check:changeset-gate-self-tests, check:objectui-changesetPASS
check:cross-package-test-inputsPASS (12 packages)
check:durability-log-levelPASS (66 read seams, none invents)
check:filter-alias-parityPASS
check:query-options-erasurePASS (no files added to baseline)
check:type-check-coveragePASS
check:type-check-debt --re-measurePASS — 33 entries, none above its recorded number
check:engine-double-contractPASS — 266 pinned, my new double is PINNED, no ledger row
check:error-code-casing, check:nul-bytesPASS
check-adr-0087-registration, check-changeset-no-major, check-empty-changesetPASS

Added beyond the script's derived list (it derives from paths and cannot see
diff-content-keyed families): check:engine-double-contract (new fake engine),
check:error-code-casing, check:nul-bytes.

Tests, with the consumption radius run rather than just the edited package:

  • @objectstack/metadata-protocol — 103 files, 1496 tests pass
  • @objectstack/objectql — 210 files, 3675 tests pass (the consumer that went red
    on fix(metadata-protocol): fold publish Phase 2's object-table lookup at the producer #8867 after a green edited-package suite)
  • @objectstack/rest — 118 files, 1948 tests pass (owns the route)
  • typecheck green for all three
  • TEST_DEBT measured directly on a built workspace, since metadata-protocol
    hides **/*.test.ts from tsc and a green typecheck carries zero information
    about the ratchet

Second commit at HEAD 3dae00805 — docblock only

3dae00805 adds the two @throws entries to diffMetaItem's docblock and
changes nothing else (one hunk, 18 insertions, 0 deletions, no behaviour). Gates
re-derived for that single path and re-run at the new head:
check:cross-package-test-inputs, check:durability-log-level,
check:filter-alias-parity, check:nul-bytes — all PASS. The ratchet families
are not re-run for this commit by explicit instruction, and are structurally
unmovable by it: check:type-check-debt counts tsc errors, query-options-erasure
counts sites in test files, and engine-double-contract scans doubles — a
comment-only diff touches none of those inputs.

The pins are proved capable of failing

#8867 in this lane shipped a pin whose fixture made folded and raw agree, so it
could not fail. Both halves here were ablated and observed red:

Fold ablated (canonicalizeMetaRequestType call removed, manifest lookup
restored) — 4 red, and the failure text is the defect itself:

AssertionError: expected { type: 'fields', ...(6) } to match object { added: [], removed: [], ...(1) }
- "changed": [ { "from": "A", "path": "label", "to": "B" } ],
+ "changed": [],

plus expected 'fields' to be 'field' on the echo and the 400 refusal going
unrefused. The #8833 arms stayed green — the two changes are independent.

503 ablated (swallow restored) — the two outage pins plus the two re-pointed
#8798 pins went red (expected null to be an instance of Error), while
THE CONTROL: a genuinely-absent table still answers benignly stayed GREEN.
That pair is what shows the discrimination is selective rather than a blanket
"any failure 503s".

One observed direction did not match my prediction, reported rather than
smoothed over.
I expected the manifest-PRESENT control (views) to stay fully
green under the fold ablation; it went red. Cause: my first version compared whole
response objects, and the echo moves for manifest-present types too (the echo
never read the manifest map). The control has been split so each half states its
own fact — body arms (unchanged by this fix, which is the control's real claim)
and echo arm (does move) — and the comment records the measurement. Re-ablated
after the split: body arms green, only the echo arm red, at
protocol.diff-canonical-type-and-history-outage.test.ts:255.

Fixture triage on the pre-existing pins

protocol.diff-dead-history-read.test.ts had two #8798 pins asserting that an
outage falls through to an empty diff — exactly the limb this PR deletes. They
were re-pointed, not deleted: their subject was never "an outage is empty" but
"one outage, one answer, not decided by the type", and both arms still pin that,
now against the 503. Their stub throws a generic Error, which
isMissingTableError does not classify as benign, so it is a genuine outage under
the new rule. The benign case gets its own positive control in the new file.

Scope

Edits are confined to diffMetaItem — the region declared at dispatch
(origin/main @ 8664a2c99:15911 → end of method) plus its docblock
(:15902:15910), the latter under a narrow widening the PM authorized after I
stopped and reported rather than taking it myself. rethrowUnlessMetadataStoreUnprovisioned
is called, not modified. No sibling region touched, and no content/docs/**
change: http-protocol.mdx documents neither this endpoint nor any 503, so
nothing there is stale.


Generated by Claude Code

…stop swallowing a history outage
Two independent changes in one method, both on `diffMetaItem`:
- #8868: route the request through `canonicalizeMetaRequestType` instead of
the `PLURAL_TO_SINGULAR` manifest map. The ninth `/meta` entry point was
the last one still deriving its type key from that map, so a plural of a
manifest-absent type (`fields`) reached the history query unfolded, matched
no row, and answered a well-formed EMPTY diff for an item that has history.
The #7894 boundary refusal now also runs here (400 INVALID_REQUEST).
- #8833: the empty-bodied `catch` left `histRows` at [] and the code below
read that as a real answer, so a history outage was served as 200 with an
empty diff. It now routes through `rethrowUnlessMetadataStoreUnprovisioned`:
a genuinely-absent table keeps its benign answer, every other read failure
propagates 503 (ADR-0110 D3).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…tage 503
New file protocol.diff-canonical-type-and-history-outage.test.ts pins both
halves, each with a discriminating control:
- the fold uses `fields` (manifest-ABSENT, so folded and raw DISAGREE) as the
subject, with `views` (manifest-present) as the control and an explicit
anti-vacuity arm asserting PLURAL_TO_SINGULAR still does not fold `fields`.
Ablating the fold turns the subject red on its BODY (changed: []) while the
control's body arms stay green — only its echo arm moves.
- the 503 pins the ADR-0112 envelope (code AND status) for a non-benign read
failure, with a genuinely-missing-table positive control proving the
discrimination is selective rather than blanket.
Also re-points the two #8798 outage pins, which asserted the swallow this
change removes. Their subject (one outage, one answer, not decided by the
type) is unchanged; the asserted value moved from an empty diff to the 503.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 15, 2026 5:06pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…on its docblock
The method's thrown-error contract changed from "answers 200" to "may answer
400 or 503", and a contract change that lives only in an in-body comment is the
declared-not-enforced shape this repo keeps paying for. The docblock is what a
caller reads.
States the DISCRIMINATION rather than just the code: a genuinely unprovisioned
sys_metadata_history still answers a benign empty diff, and that asymmetry is
the whole point of the #8833 ruling. Follows the file's existing @throws idiom
(getEffectiveLock, the layered read).
Docblock only — no behaviour change, no other method touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@hotlong
hotlong marked this pull request as ready for review August 15, 2026 19:30
@hotlong
hotlong added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 3043e98Aug 15, 2026
29 checks passed
@hotlong
hotlong deleted the claude/issue-8868-diff-verb-fold-and-503 branch August 15, 2026 19:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude