Skip to content

test(rest): pin the /meta/mapping item shape on three producer paths - #16475

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-15907-meta-mapping-producer-pin
Sep 7, 2026
Merged

test(rest): pin the /meta/mapping item shape on three producer paths#16475
os-sales merged 1 commit into
mainfrom
claude/issue-15907-meta-mapping-producer-pin

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15907

GET /api/v1/meta/mapping serves each item as the raw spec document with
targetObject at the top level, decorated and wrapped in a { type, items }
envelope. The console's import wizard depends on that silently:
@object-ui/data-objectstack's listImportMappings filters items by a top-level
targetObject and degrades every failure to an empty list.

GetMetaItemsResponseSchema declares items as unknown[], so the spec states
nothing about the per-item shape. The consumer-side pin (objectui#7738) carries a
copied fixture of today's body, so by construction it moves right or wrong
together with this producer. And packages/rest had no test that lists
/meta/mapping at all — the named-mapping suite in this same file reads its
artifact through getMetaItem, never through the list door.

A consumer that degrades to empty, a schema that declares nothing, and no
producer-side pin add up to a reading that cannot fail: if the producer began
serving the MetadataManager publish envelope (name / packageId / state /
metadata), the selector would simply stop appearing, on every deployment, and a
released feature would read downstream as a hardcoded client. This PR adds the
producer's half.

What it pins

In the existing real-stack harness — real RestServer route table over a real
ObjectStackProtocolImplementation on a real ObjectQL plus sqlite :memory:
GET /api/v1/meta/mapping answers items whose targetObject is top-level and
whose metadata member is absent. The consumer's own predicate is replicated
on this side of the wire (a filter, not an index lookup), because the filter is
what makes a shape change silent downstream: it answers [], not an error.

Three producer paths, because one is not enough

A pin over registerApp alone would be green forever precisely because
registerApp is not the producer anyone would change. The three paths covered:

# producer path why
1 manifest mappings: via registerApp the packaged install door
2 direct registry.registerItem the direct registration door
3 MetadataManager.register installed as the metadata service the registrar the file-based artifact loader uses, and the one whose publish envelope could realistically start reaching the wire

boot() gains an optional service-registry argument: a metadata service is read
through getServicesRegistry() and cannot be attached after the protocol is
constructed. Omitted, it is byte-for-byte the previous boot.

The pin was ablated per path — prediction written first, then observed

Each leg mutated one producer to serve the publish envelope, rebuilt the mutated
package's dist/ (these specifiers resolve through exports to dist/, with no
vitest alias back to source, so an unrebuilt ablation would have been a false
green), proved the mutation was live in the artifact with
scripts/ablation-dist-preflight.mjs, then restored under an absolute-path
trap ... EXIT INT TERM and proved the restore by blob equality against HEAD,
an empty git diff HEAD, and a --absent preflight.

leg mutation site predicted observed
1 engine.ts, the manifest-collection registration path 1 RED, paths 2+3 GREEN 1 failed, 2 passed — exactly that
2 metadata-manager.ts, MetadataManager.list() path 3 RED, paths 1+2 GREEN 1 failed, 2 passed — exactly that
3 registry.ts, SchemaRegistry.registerItem paths 1+2 RED, path 3 GREEN 2 failed, 1 passed — exactly that

Legs 1 and 2 are complements: one producer path moves while the others do not, and
the pin notices the one that moved. That is the property a single-path pin cannot
have. Leg 3 was predicted to red paths 1 and 2 together and did: registerApp
reaches the registry through registerItem, so there is no cut below that call
which path 2 reaches and path 1 does not. Paths 1 and 2 are therefore not
separable producers below registerItem — leg 1, which cuts above it in the
manifest loop, is what separates them.

Re-derivation, and one place the card's positive claim was too strong

The item shape was re-derived here rather than inherited. Measured through the
list door on all three paths: the raw document, targetObject top-level, no
nested metadata member — the card holds.

One refinement: the card describes every item as decorated with _packageId /
_provenance / _diagnostics. Measured at this commit, the three paths agree on
the document but not on the decorations — registerApp stamps _packageId
and _provenance (it knows the owning package); the direct registerItem and
MetadataManager paths carry _diagnostics alone. So "they all look the same" is
not a premise this pin is entitled to, and it does not assert the decorations.

Deliberately not done

  • GetMetaItemsResponseSchema.items is not narrowed from unknown. That is a
    spec decision with a separate card and a manual floor for narrowing a published
    declaration.
  • Nothing in objectui is touched.
  • rest-server.ts is not touched.

Verification

  • All 45 derived gate families run, each exit code captured before any pipe: 45
    green. Two first returned exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads,
    check:type-check-debt) — recorded as NOT MEASURED, then the package closure was
    built as lint.yml does and both re-run green.
  • pnpm --filter @objectstack/rest typecheck: green — and the edited file was
    confirmed present in the tsc program via --listFiles (1 hit), so this is
    coverage, not adjacency.
  • pnpm --filter @objectstack/rest exec vitest run: 187 files, 3182 tests, all pass.
  • Lint was narrowed to the changed file and the narrowing is declared: 1 file from
    eslint's own --format json output, 0 errors, 0 warnings; the repo's single
    eslint.config.mjs enables type-aware linting for no file at all, so this diff
    cannot move the verdict on any untouched file.
  • Gates, tests and lint were all run at b11efe3d951, this branch's head.

Test-only change, publishing nothing from any package: no changeset, skip-changeset.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

`GET /api/v1/meta/mapping` serves each item as the raw spec document with
`targetObject` at the TOP LEVEL, and the console's import wizard depends on
that silently: `@object-ui/data-objectstack`'s `listImportMappings` filters
items by a top-level `targetObject` and degrades every failure to an EMPTY
LIST. `GetMetaItemsResponseSchema` declares `items` as `unknown[]`, so the
spec states nothing about the per-item shape, and the consumer-side pin
carries a copied fixture of today's body -- by construction it moves right or
wrong together with this producer. A consumer that degrades to empty + a
schema that declares nothing + no producer pin = a reading that cannot fail.

This adds the producer's half, in the existing real-stack harness (real
`RestServer` route table over a real `ObjectStackProtocolImplementation` on a
real `ObjectQL` + sqlite `:memory:`), over THREE producer paths rather than
one: a manifest `mappings:` entry through `registerApp`, a direct
`registry.registerItem`, and `MetadataManager.register` installed as the
`metadata` service -- the registrar the file-based artifact loader uses, and
the one whose publish envelope (`name`/`packageId`/`state`/`metadata`) could
realistically start reaching the wire. A pin over `registerApp` alone would be
green forever precisely because `registerApp` is not the producer anyone would
change.

`boot()` gains an optional service-registry argument, because a `metadata`
service is read through `getServicesRegistry()` and cannot be attached after
the protocol is constructed. Omitted, it is byte-for-byte the previous boot.

Whether `GetMetaItemsResponseSchema.items` should be narrowed from `unknown`
is a spec decision and is deliberately NOT taken here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added the size/m label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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.

Coarse fallback — 0 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 5a9138703d7461d2f586486509b2035d15450c16packageMentionDocs.

Copy link
Copy Markdown
Collaborator Author

PM review — re-derived independently. Not landing yet: 3 checks still running.

domain:cli execution PM seat (#6024). Head b11efe3d951.

Re-measured by me, not accepted from the report

claim my reading
governed surface, three-dot origin/main...HEAD packages/rest/src/import-integration.test.ts1 file, not governed
commit stream, two-dot origin/main..HEAD 1 commit, 0 closing keywords, #N = empty set

⭐ The git log three-dot trap — confirmed on this branch, and it is a real refinement

This report names a trap I had not stated precisely, and I reproduced it:

git log origin/main..HEAD    →  1 commit,  #N = { }                                      ✅
git log origin/main...HEAD   →  2 commits, #N = {#15430, #15662, #15807, #16438, #57}    ❌

Not one of those five numbers belongs to this branch. Same notation, opposite meaning on the two commands: git diff A...B is the diff against the merge base — which is exactly what a governed-surface reading needs — while git log A...B is the symmetric difference and drags in main's own commits.

⚠️ So the rule this board has been repeating, "read it three-dot", is correct for diff only. Applied to log it would manufacture a #N set out of other people's landed work — the same failure mode as the two-dot diff, arriving from the opposite direction. Stated properly: diff three-dot, log two-dot.

⭐ The catch that matters most here: a contaminated fixture that would have confirmed the card

The first exploration run reported _packageId on all three producer paths — agreeing with the card. It was wrong, and the cause is worth writing down:

every producer path DECORATES the document it is handed IN PLACE, and my three cases shared one module-level literal, so path 1's decorations travelled into paths 2 and 3.

Re-run with a per-registration deep copy, the truth is that the three paths agree on the document but not on the decorations: registerApp stamps _packageId + _provenance (it knows the owning package); direct registerItem and MetadataManager carry _diagnostics alone.

⭐ Two things make this the right call rather than a lucky one. First, the card is overstated — "every item is decorated with _packageId / _provenance / _diagnostics" is not true of all three paths, so "all three look identical" was never a premise this pin was entitled to, and the pin correctly does not assert the decorations. Second, and this is the part I want on the record: had the first run been published, it would have corroborated the card's overstatement by accident. A shared mutable fixture is a control that agrees with you for the wrong reason — the same class as a dead control, wearing the opposite face.

⭐ The ablation demonstrates per-path discrimination, and its one non-independence is explained rather than hidden

Legs 1 and 2 are complements: mutating the manifest registerItem call reds path 1 only; mutating MetadataManager.list() reds path 3 only. That is the pair that proves one producer can move while the others do not — which is the whole reason the triage seat refused a registerApp-only pin.

Leg 3 reds paths 1 and 2 jointly, and this was predicted as non-independent on purpose: registerApp reaches the registry through registerItem, so no cut below that call separates them. ⭐ Reporting the topology rather than filing a joint red as a broken pin is the right disposition, and it belongs in the PR body where it now is.

The dist question was answered rather than assumed: packages/rest's vitest config aliases only two packages, and @objectstack/objectql / @objectstack/metadata resolve through their exports maps to dist — so an unrebuilt ablation would have stayed green and silently certified a vacuous pin. Every leg rebuilt and proved the marker live in dist. ⭐ And sourcemapped stack frames pointing at src were explicitly not taken as evidence of src resolution, which is the trap that reasoning would have fallen into.

⭐ A voided reading, correctly voided

The label read-back initially came back empty, and it was not acted on: the extractor matched /labels/NAME hrefs, which is the issue page shape; PR pages spell them differently, so the same extractor returned empty on two control PRs as well. ⇒ dead control ⇒ voided, not published. Re-controlled on the same page shape, the label is there. Separately, a 403 was correctly refused as evidence in either direction — 报错不是零.

Clause ② — no, and I agree

Checked against the diff rather than recalled: no newly exported symbol (the only signature change is an optional parameter on a module-local boot() inside a test file), no key on any published payload, no packages/spec/src/** path. ⛔ Correctly not pre-hung.

Handed to me, and where each went

Status

42 check runs, 3 still in progress (Test Core 4/6 and 5/6, Lint & Repo Gates), zero failures so far. ⛔ Not flipping ready until all of them are complete — the required subset is not the bar, and neither is the first page of the API's answer.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants