fix(metadata): RemoteLoader.list() no longer reports a nameless body as a literal undefined - #16107
Conversation
…as a literal `undefined`
`list()` declares `Promise<string[]>` and read the collection as
`loadMany<{ name: string }>(type)` before `items.map(i => i.name)`. That type
argument is an assertion about bodies that arrived over HTTP; nothing checked
it. A body with no top-level `name` yielded `undefined`, pushed into an array
the signature declares as `string[]`, and `MetadataManager.listNames()` unions
loader `list()` output unfiltered — so the violation reached consumers. On this
fixture `listNames()` answered `[ 'account', undefined, 42 ]`.
The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map
spelling, `typeof name === 'string'` behind it. `RemoteLoader` was the only one
of the four loaders in the directory with no guard at all. The predicate is
spelled as a type guard with the mapped element left `unknown`, so `tsc` proves
the declared `string[]` instead of a cast asserting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ssue-15037-remote-loader-list-nameless
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2adff617054e94bf6eef7bbc7c4dd967a5938fcd && git checkout 2adff617054e94bf6eef7bbc7c4dd967a5938fcd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 64011dd3fd2fc4352951f26c71da5c824abc6050 20cf207c406b97e9dc533a9e25f8079812c8aa6e && git checkout -B drift-repro 64011dd3fd2fc4352951f26c71da5c824abc6050 && git merge --no-ff 20cf207c406b97e9dc533a9e25f8079812c8aa6e
node scripts/docs-audit/affected-docs.mjs --json 64011dd3fd2fc4352951f26c71da5c824abc6050
|
Fixes #15037
RemoteLoader.list()declaresPromise<string[]>and read the collection asloadMany<{ name: string }>(type)before mappingitems.map(i => i.name). That type argument is an assertion about bodies that arrived over HTTP, and nothing checked it: a body with no top-levelnameyieldedundefined, which went into an array the signature declares asstring[].MetadataManager.listNames()unions loaderlist()output unfiltered, so the violation reached consumers — a runtime violation of a declared type, not an untidy entry.The repair is one file away, and the direction was already decided
RemoteLoaderwas the only one of the four loaders inpackages/metadata/src/loaders/with no guard at all. Anchors re-derived on the merged tree:list()does todayundefined?DatabaseLoader:1062rows.map(row => row.name as string)followed by.filter(name => typeof name === 'string'):1076-1078MemoryLoader:97Array.from(typeStore.keys())FilesystemLoader:366findFile()resolvesRemoteLoader:113items.map(i => i.name)So this copies
DatabaseLoader's guard shape: same directory, same method name, same cast-then-map spelling, onetypeoffilter behind it."Refuse loudly" was not taken, and that is a landed decision rather than a preference.
DatabaseLoader's guard is a silent.filter(), andFilesystemLoader's narrowing carries a maintainer ruling (director seat, #14486, 2026-09-02, direction A: narrow the list, with B explicitly refused). That ruling's own docblock reasoning points straight here:An
undefinedinlistNames()is the extreme form of a name the door can never answer.Two things deliberately NOT copied.
MemoryLoader's structural fix (return the store key) and #14205's keying rule ("identity is the key the store holds an item under, notbody.name") —RemoteLoaderreads over HTTP and holds no store key, sobody.nameis the only identity it has and that rule cannot be satisfied here.loadMany()is deliberately untouched: it keys nothing, so a nameless body is still served there.Not a duplicate of the closed neighbours
This table is preserved from the card because it is what stops this being closed as a dupe.
undefined. The code repaired here is what was onmainafter that card closed.The shape of the guard
The predicate is spelled as a type guard and the mapped element type left
unknown, sotscproves the declaredstring[]instead of a cast asserting it. Spelled with the original{ name: string }cast, the compiler reads the filter as always-true and a later reader deletes it as dead.Verification
Everything below was measured on the merged tree at
20cf207c4(this branch merged withorigin/main1157e7b72; merge, never rebase).Ablation — the test genuinely reddens.
RemoteLoader.list()reverted to the pre-fix shape, mutation proven on disk before measuring (anchored grep counts flipped 1→0 and 0→1;git hash-object279ddc56differs from the HEAD blob2a7b5dce), then restored and the restore proven (blob equals HEAD blob,git diff HEADempty, whole-treegit statusempty). No rebuild leg applies and that is a measurement, not an assumption: the test imports the subject relatively and in-package (./remote-loader.js,../metadata-manager.js), so vitest resolvessrc/directly — the package'sexportsmap anddist/are not on the resolution path, and this package's vitest aliases cover only@objectstack/core,/specand/types.The 6 that stayed green under mutation are the
CONTROL:andRECORD:cases — they pin what must not move, so a guard that dropped everything would fail them. The failures reproduce the defect literally:'account'is the in-run control: a well-formed body stays listed and stays loadable throughout.Tests.
pnpm --filter @objectstack/metadata test— 49 files / 757 tests passed.pnpm --filter @objectstack/metadata typecheck— clean. The typecheck is a real reading about the new file, not a vacuous one:tsc --noEmit --listFilescontainsremote-loader-list-nameless.test.ts(count 1), this package'stsconfig.jsonincludessrc/**/*and excludes onlynode_modulesanddist, and it carries notest-typecheck-debt.json.Pin shape. Triage named the model and it is reused on the
RemoteLoaderface:filesystem-loader-list-reachability.test.ts:210/:218is already "listNames()andget()give the same answer for the same name". That sibling pin was run alongside and stays green.Gate union — 53/53 families, re-derived after the final commit.
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(provenance line checked; harvested with--commands, never by grepping the prose). 51 exit 0. Two returned exit 3 = PREREQUISITE NOT MET, which their own verdict lines state is neither a pass nor a finding:check:dual-build-cjs-loads— "this gate reads built output, and some package has no dist/ … This is NOT a pass: nothing was measured." 57 packages unbuilt.check:type-check-debt— "--re-measurecannot run: 20 workspace dependencies … have no built type entry point on disk … This is NOT a pass and NOT a finding."Both need the whole-workspace build CI performs before those steps, so they are recorded NOT MEASURED and left to CI rather than run half-built — the debt gate states outright that a number measured without the closure "would silently measure a DIFFERENT WORLD". Both self-tests passed. Neither is plausibly moved by this diff: it adds no entry point, export or import, and
@objectstack/metadatais not in the DEBT or TEST_DEBT ledgers.Lint. The full repo sweep
pnpm lint(eslint . --no-inline-config) ran to completion: exit 0. No narrowing was used, so none needs justifying.Docs.
scripts/docs-audit/check-affected-docs.mjsexit 0. Measured rather than assumed: the onlycontent/docs/**mention ofRemoteLoaderis a one-line capability row incontent/docs/protocol/kernel/metadata-service.mdx:67("Fetches metadata from a remote HTTP API"), which does not documentlist()'s nameless-body behaviour — nothing published is falsified, so no doc repair is owed.Clause-② — both limbs re-derived: no
Measured with the corrected instrument: build at head, swap
remote-loader.tsback to1157e7b72, rebuild, and diff every declaration file the package publishes resolved from itsexportsmap andfiles[](dist/**, 10 files) — not the root barrel alone.Limb 1 — does any exported symbol or signature move? No. Two of the ten declaration files differ in bytes (
dist/index.d.ts,dist/index.d.cts), and reading the bytes alone would have produced a false "widening". The textual diff is the added JSDoc block and nothing else; the declaration itself is byte-identical before and after:The other eight published declaration files are byte-identical. The restore leg was rebuilt too, and
dist/index.d.tshashes back to the head snapshot exactly (6c459008), so no mutant output was left behind for later runs to measure.Limb 2 — is any request newly accepted or rejected? No. Nothing validly returned before stops being returned; the only entries that disappear are the ones the declared type already ruled out. A caller that previously received
[undefined]now receives[]— that is a declared-type violation being removed, not an accept set moving. The changeset is thereforepatch.Grade
priority:p3is unchanged — this round measured neither of the two things triage said decide severity, and had no means to: population (how many deployments useRemoteLoader, how many remote bodies lack a top-levelname) and a demonstrated downstream break. The promotion condition stands as triage wrote it: any single such measurement re-grades to p2.Recovery-round note
The previous round was killed mid-flight by a container restart, leaving one pushed commit and no PR and no report — so no verification record was inherited, and the pushed diff was re-read adversarially rather than trusted. Found already on the branch: the guard, the test file and the changeset. Added this round: the
origin/mainmerge, the re-derived anchors, and the entire verification record above — ablation with on-disk mutation and restore proofs, the 53-family gate union, the full lint sweep, the docs verdict, the--listFilescoverage proof and the Clause-② declaration-file instrument.Authored by Claude Code in session
session_01ARYe3yQTQCUFm5qPYNgKaJ.Generated by Claude Code