Repository navigation
metadata-protocol's 422 issues[] does not descend invalid_key / invalid_element containers, so a record-key rejection arrives as the bare "Invalid key in record" while the CLI and the wire both show the prescription #8783
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 15, 2026 Triage: lands in
packages/metadata-protocol⇒domain:metadata; gradedpm:queue, typeBug. The filer'sdomain:metadatawas correct and is kept — the landing file ispackages/metadata-protocol/src/protocol.ts, which the lane table anchors tometadataby package.The grade question is answered: yes, reachable from authored metadata today
The card correctly said the grade hinges on whether any schema in the
getMetadataTypeSchemaregistry has az.record/z.mapwhose KEY schema can reject, and left it unmeasured. Measured onorigin/main@84cb121:packages/spec/src/data/object.zod.ts:1654 fields: z.record(z.string().regex(/^[a-z_][a-z0-9_]*$/, { message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")', }), FieldSchema) packages/spec/src/kernel/metadata-type-schemas.ts:87 object: ObjectSchema,ObjectSchema.fieldsis a constrained-key record, andobjectis in the builtin registry. So this is not dormant drift: any author who declares a field key that is not snake_case —firstName,"My Field",1field— goes throughsaveMetaItem→422 INVALID_METADATA→zodIssuesToMetadataIssues, and receives{ path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' }with the prescription that names the actual rule — "Field names must be lowercase snake_case" — dropped one level down. This is the highest-traffic authoring mistake surface on the platform, on the single most-authored metadata type.
Zero-hit discipline satisfied:
CONTAINER_ISSUE_CODESreturns zero hits anywhere inpackages/metadata-protocol, and the counter-probe confirms the symbol is real and exercised elsewhere —packages/spec/src/api/zod-issues-to-fields.ts:187,packages/spec/src/shared/error-map.zod.ts:207, andunion-branch-policy.parity.test.ts:252asserts its membership is exactly['invalid_element', 'invalid_key'].Why this grades
pm:queuerather thanfindingThe third evaluation axis is decisive. A loud rejection whose prescription is stripped is the inverse of the silent-tolerance failure mode, and it lands on exactly the population that most needs the prescription: an agent authoring a metadata app, coming from JS naming, writes
firstNameand is told only that something is invalid. It re-guesses. The declared message exists and is correct — it simply does not reach the author, which is adeclared ≠ deliveredgap, not a contract question. Accept set unchanged ⇒Bugby the mechanical border test, and no escalation is owed.⚠️ Hot-file serial constraint —protocol.tsnow carries four cards, and the lane seat is vacantpackages/metadata-protocol/src/protocol.tsis 16,480 lines and is currently the landing site for:card state region #7643 pm:dispatchedsearchAll— recall half#8660 pm:dispatchedunion-branch ranking (PR #8782, parity test only — touches no production line here) #8783 (this) pm:queuezodIssuesToMetadataIssues—:698#8786 pm:queuesearchAll/renderTitle—:8603This card's region (
:698) is disjoint from all three others, so it is the one of the four that can be claimed without waiting. Claim must declare region-level file surface, not the bare file.domain:metadataseat #6367 is vacant, so no seat is holding the serial queue for this file — flagged to the maintainer in this round's brief.Scope note carried from the card and endorsed: whether the third copy should instead be rewired onto a promoted shared export is #8660's option 2, a public-surface decision under #4001 — ⛔ not implied by this card and not to be bundled into it.
Generated by Claude Code
Claim: PM loop round 5
Session:session_01XeQRiAa7vYRVX5Fog7Zby8
Branch:claude/issue-8783-container-issue-descent
Worktree:objectstack-issue-8783
Domain:domain:metadata
File surface:packages/metadata-protocol/src/protocol.ts— region-declared:zodIssuesToMetadataIssuesat:698— plus its co-located pins. ⛔ Stop on breach and report.
Container & model:S–M,mode:subagent,model: opus— the edit is small but it changes a published 422 envelope's contents, and judging what belongs inissues[]is a judgement.
Serial constraints cleared:protocol.tsruns under region-level declaration. Re-measured onorigin/maind09d0fdbdat claim time, from the tree: your region:698vssearchAll/renderTitle:8552/~:8640(#8786, in flight) anddiffMetaItem:15349(#8671, PR #8797, patch lap in flight). Your region is thousands of lines clear of both — it is a module-level function near the top of the file, not a method on the class.auditMetaItem:6091(#8747) has its PR open at #8803, no live editor. #8770 (#8421) is a held draft, parked. Triage's four-card table is now stale in your favour: #7643 and #8660 both landed, so only two live editors remain.Triage answered the grade question — ⛔ do not re-open it
The card filed this with its first triage question unmeasured: "is it reachable from real authored metadata today?" Triage measured it on
origin/mainand the answer is yes:packages/spec/src/data/object.zod.ts:1654 fields: z.record(z.string().regex(/^[a-z_][a-z0-9_]*$/, { message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")', }), FieldSchema) packages/spec/src/kernel/metadata-type-schemas.ts:87 object: ObjectSchema,ObjectSchema.fieldsis a constrained-key record andobjectis in the builtin registry. So an author who writesfirstName,"My Field"or1fieldgoessaveMetaItem→422 INVALID_METADATA→zodIssuesToMetadataIssuesand receives:{ path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' }— with the sentence that names the actual rule dropped one level down, in
issue.issues. This is the highest-traffic authoring mistake on the single most-authored metadata type. ⛔ This is not dormant drift and is not to be re-graded as an observation.Zero-hit discipline is already satisfied and I am carrying it so you do not repeat it:
CONTAINER_ISSUE_CODESreturns zero hits anywhere inpackages/metadata-protocol, counter-probed against three real uses elsewhere —packages/spec/src/api/zod-issues-to-fields.ts:187,packages/spec/src/shared/error-map.zod.ts:207, andunion-branch-policy.parity.test.ts:252, which asserts the set is exactly['invalid_element', 'invalid_key'].The settled shape
#5389already taught the twopackages/specwalks to descend these container codes, because zod puts a bare wrapper message on the issue and the real diagnosis inissue.issues— structurally the same asinvalid_unionhiding a branch's prescription inerrors(#4971).zodIssuesToMetadataIssuesexpandsinvalid_uniononly and has noCONTAINER_ISSUE_CODESequivalent, so it stops at the wrapper.Follow the existing
invalid_unionexpansion in this same function as your model — you are adding a second descent alongside a descent that already works, not inventing one.PM mechanism assumption (measure it; falsify it if wrong)
I expect the descent to be additive: the wrapper entry stays and the descended entries join it, matching what the wire walk already emits (
zodIssuesToFieldsproduces both theinvalid_shapewrapper and themin_lengthdetail for the card's repro). Measure what the two spec walks actually emit and match them — if they drop the wrapper instead of keeping it, match that, and say so. The whole point of this card is that three walks over onesafeParsedisagree; do not fix the disagreement by inventing a fourth answer.⛔ Explicitly out of scope
Whether this third copy should instead be rewired onto a promoted shared export is #8660's option 2 — a public-surface decision under #4001. It is neither implied by this card nor bundled into it. ⛔ Do not promote anything from
packages/spec, and do not add an export.Note that #8660's parity test (
union-branch-policy.cross-package-parity.test.ts) landed a few minutes ago and its §1 asserts that none of the seven policy symbols is reachable from any public@objectstack/specentry point. If your change would make that assertion false, you have crossed into option 2 — ⛔ stop and report.Verification bar
Pin the card's own measured repro end-to-end:
z.object({ m: z.record(z.string().min(4), z.string()) })parsing{ m: { ab: 'x' } }must yield the descended prescription, not just the bare wrapper. Then pin the real one, because that is the one that matters: an authored object with a non-snake_case field key must surface "Field names must be lowercase snake_case" through the 422 envelope.Keep a discriminating control in the same test — an
invalid_unioncase still expanding as it does today, and a plain non-container issue passing through unchanged — so the pin shows you added a descent rather than widened the walk indiscriminately.#8660's parity corpus is the natural place to extend coverage now that its file is on
main; adding a container case there is welcome but optional, and ⛔ must not change what §1 asserts.
Generated by Claude Code
{ "issue": 8783, "status": "done", "branch": "claude/issue-8783-container-issue-descent", "pr": "https://github.com/objectstack-ai/objectstack/pull/8817", "premise_still_valid": true, "summary": "zodIssuesToMetadataIssues now descends invalid_key / invalid_element alongside the invalid_union expansion already in that function, so the 422 INVALID_METADATA envelope carries the rule a rejected record key broke instead of stopping at zod's bare 'Invalid key in record' wrapper. Modelled on the invalid_union descent next to it: a module-local CONTAINER_ISSUE_CODES set, an issues?: unknown field on ZodIssueLike, and the contained branch in collectMetadataIssues; the depth constant was renamed UNION_EXPANSION_DEPTH_LIMIT -> NESTED_EXPANSION_DEPTH_LIMIT because it now bounds both descents, matching the spelling the spec pair adopted for the same reason in #8318. Region declaration held: every hunk sits in protocol.ts lines 503-790, re-derived from the merged tree; the nearest other landing site is auditMetaItem at :6187. No export added, nothing promoted from packages/spec, and #8660's parity-test §1 is untouched and green.", "tests": "PM MECHANISM ASSUMPTION CONFIRMED, not assumed: before writing anything I drove one safeParse through all three walks. Both spec walks are ADDITIVE — formatZodIssue prints the wrapper line then the indented detail, zodIssuesToFields emits {field:'m.ab',code:'invalid_shape'} then {field:'m.ab',code:'min_length'} — so this envelope matches them rather than inventing a fourth answer. Same probe on the REAL repro: getMetadataTypeSchema('object').safeParse({fields:{firstName:...}}) raises ONE invalid_key at path ['fields','firstName'] with 'Field names must be lowercase snake_case (e.g., \"first_name\", ...)' stranded in issue.issues[0].\n\nREVERSE VERIFICATION, direction predicted in writing BEFORE running (prediction file written first): ablate the descent => 7 of 14 red, 7 green. Measured: exactly 7 red / 7 green, and the seven red were the seven named in the prediction. The fix was committed first (7ac02168e), so restoring was `git checkout <branch> -- <path>` out of a real commit — never a stash — and `git status --porcelain` read back empty (no MM).\n\nALL NUMBERS BELOW AT FINAL HEAD 97b0bc414 (origin/main merged a second time at the PM's request; closure rebuilt after the merge; tree clean, nothing committed after the run):\n- pnpm --filter @objectstack/metadata-protocol test => 95 files / 1407 tests passed (92/1387 pre-merge; the merge brought 3 new test files)\n- Downstream sweep, PREFIX filter = consumers: @objectstack/objectql 209 files / 3664 tests, @objectstack/rest 118 files / 1948 tests — all passed\n- Derived gate union (node scripts/pm/dispatch-gates.mjs against the actual 4 changed paths, re-derived at the merged head): check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:filter-alias-parity, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset — all PASS\n- Convention-triggered + unconditional: check:nul-bytes, check:engine-double-contract, check:query-options-erasure, check:type-check-coverage — all PASS. check:where-matcher PASS (run because the dispatch flagged it as firing on diff content; this diff adds no matcher).\n- check:type-check-debt (--self-test + --re-measure) PASS after building the closure. Measured for THIS package specifically per your warning, since metadata-protocol hides its tests from tsc: it appears in neither the above-ceiling list nor the surplus list, so it measured exactly its recorded 63 — the +363-line test file added zero. The only surplus reported is @objectstack/lint (-1), pre-existing on main and untouched here.\n\nNo ablation-on-dist was involved (no dogfood leg in this card), so nothing to rebuild on that axis.", "open_questions": [], "out_of_scope_findings": [] }Notes for the PM, outside the JSON
Your mechanism assumption was right, and is now pinned rather than assumed. The additive shape is asserted three ways: the wrapper stays at
issues[0]in the 422 pin, §5 of the parity test asserts all three walks agree pair-for-pair over three container fixtures, and a dedicated test asserts the wrapper's message is first in all three.One thing I changed that you did not ask for, flagged deliberately.
union-branch-policy.cross-package-parity.test.ts's header recorded container descent as a deliberate exclusion — "this package's copy expandsinvalid_uniononly". My change makes that sentence false, so leaving it would have left a confident lie in the file whose whole job is to be the mechanical record. I corrected the header and added §5. §1 is byte-untouched and green; §5 is kept out of §2'sFIXTUREScorpus on purpose, because §3 asserts every §2 fixture really produces aninvalid_union— folding a container fixture in there would have forced that vacuity guard to be loosened, which costs more than the shared list saves.The
skip-changesetlabel does not apply here — this PR ships.changeset/metadata-422-container-issue-descent.md, since the change alters a published 422 envelope's contents. Labels read back after the bots settled:documentation,size/l,tests,tooling.Sanitizer note. The first PR body lost
git checkout <branch> -- <path>to the<-plus-letter strip, inside backticks, exactly as AGENTS.md warns. Caught on read-back and rewritten without the angle brackets; the current stored body is verified clean and the footer is in its session-URL form.Worktree torn down unforced after the push (
node_modulesremoved,git worktree removewith no--force, which succeeded — proof nothing was uncommitted).
Generated by Claude Code
ACCEPTED — PR #8817 marked ready and queued. All 28 gate jobs read job-by-job; zero failures.
PM session
session_01XeQRiAa7vYRVX5Fog7Zby8, round 5. The two that were last still running were re-read individually rather than inferred:ESLint(94970854847) →success,TypeScript Type Check(94970854673) →success.You tested my assumption before writing code, which is the right order
The dispatch asked you to measure whether the descent should be additive. You drove one
safeParsethrough all three walks first, and only then matched the envelope to what you found — both spec walks are additive (formatZodIssueprints the wrapper then the indented detail;zodIssuesToFieldsemits theinvalid_shapewrapper then themin_lengthdetail). So this envelope agrees with the two that already worked rather than inventing a fourth answer, and the wrapper staying at index 0 is a decision with a reason: it is the only entry naming the slot the client sent.Running the same probe on the real repro rather than only the card's synthetic one is what makes the fix credible:
getMetadataTypeSchema('object').safeParse({fields:{firstName:…}})raising oneinvalid_keywith "Field names must be lowercase snake_case" stranded inissue.issues[0]. That is the defect as an author meets it.Targeted rather than widened — and the tripwire handled correctly
Only the two container codes open the descent; an
issuesarray on any other code is still ignored, and that is pinned rather than merely intended. The semantic distinction you drew is the load-bearing one and it is right: a union's branches are competing candidates so they are selected between, while a container has one inner schema so every issue it raised is a true statement and dropping any drops a real diagnosis. That is why container issues are deliberately not ranked the way union branches are.#8660's parity test is a tripwire and you treated it as one. Its §1 is untouched and green. But its header recorded container descent as a deliberate exclusion — "this package's copy expands
invalid_uniononly" — and your change makes that statement false, so you corrected the header and added §5 comparing the descent across all three walks. Updating the claim a tripwire encodes, rather than routing around it, is exactly the response that keeps such a test worth having. Keeping §5 out of §2's corpus so §3's vacuity guard stays strict is the kind of care that is invisible when done and expensive when not.Renaming
UNION_EXPANSION_DEPTH_LIMIT→NESTED_EXPANSION_DEPTH_LIMITbecause it now bounds both descents — and adopting the spelling the spec pair already chose for the same reason in #8318 — is converging on existing vocabulary instead of minting a parallel one. Small, and the right instinct.Verification
Reverse verification with the direction predicted in writing before running: 7 of 14 red, 7 green, and the seven red were the seven named in the prediction. The seven that stay green are the ones asserting facts about zod's raw issues or about shapes deliberately not descended — which is what makes the red half meaningful rather than a measure of the file.
Ratchet measured for this package specifically, since
metadata-protocolhides its tests from tsc: it appears in neither the above-ceiling nor the surplus list, so it measured exactly its recorded 63 and the +363-line test file added zero.On the second merge and the wedged gate
You merged
origin/maintwice — the second at my request, after the base had moved eight commits with three landing in this same file. That was owed regardless, and it also cleared a genuinely stuck gate:Check Changesethad beenin_progressfor 42 minutes on the old head (workflow run frozen atupdated_at 04:30:43, siblings finished, cancel-and-rerun refused with403 already running). On the fresh head the same job completed in 47 seconds. Diagnosis recorded here so the next seat facing a lone stalled job against a known-fast norm reaches for a new head rather than for patience.⛔ I did not land around it while it was
in_progress— a pending gate-bearing job is not a pass.Docs
Drift check flagged 3 pages; no action. This changes what a 422 envelope says, never whether a save is refused, and no page documents
issues[]contents.Landing
ready → auto-merge → merge queue. Confirmation on both readings — the timeline merge event and the artifact on
origin/main.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
Found while building the cross-package union-branch parity test for #8660 (PR #8782). Filed unassigned, out of that card's scope: #8660 is about the union-branch ranking, and this is a difference in reach. Dedup checked: searches over open issues for
invalid_key/invalid_element,CONTAINER_ISSUE_CODES,zodIssuesToMetadataIssuesand the 422 expansion returned nothing.What
#5389 taught the two
packages/specwalks to descend the container issue codes —invalid_key(az.record/z.mapkey schema rejected a key) andinvalid_element— because zod puts a bare wrapper message on the issue itself and the real diagnosis one level down inissue.issues, exactly asinvalid_unionhides a branch's prescription inerrors(#4971).zodIssuesToMetadataIssuesinpackages/metadata-protocol/src/protocol.ts— the walk behindsaveMetaItem's422 INVALID_METADATA(#5364) andcomputeMetadataDiagnostics(#5598) — expandsinvalid_uniononly. It has no equivalent ofCONTAINER_ISSUE_CODES, so it stops at the wrapper.Measured
z.object({ m: z.record(z.string().min(4), z.string()) })parsing{ m: { ab: 'x' } }, onesafeParsefeeding all three walks:The Studio-facing envelope is the one that loses the sentence saying what is actually wrong — the #4971 failure shape, one door over.
Why it is filed rather than fixed in PR #8782
That PR is the ruled option-1 parity test and touches no production line;
protocol.tswas explicitly outside its file surface. Its corpus therefore stays insideinvalid_union, where all three copies make the same claim, and its header records this gap as a known, deliberate exclusion.First triage question, which decides the grade
Is it reachable from real authored metadata today? The repro above is synthetic. What matters is whether any schema in the
getMetadataTypeSchemaregistry has az.record/z.mapwhose KEY schema can reject — a constrained key, not a plainz.string()key. If none does, this is dormant drift and should be graded as an observation. If one does, every author hitting it gets a 422 that names the slot and then says nothing useful, and Studio's designer has no prescription to render.Fixing it is small and has a settled shape — the spec side already exports nothing here, but the code set and the descent are six lines, and PR #8782's parity corpus is the natural place to extend coverage once the walk descends. Whether the third copy should instead be rewired onto a promoted shared export is #8660's option 2, which is a public-surface decision (#4001) and not implied by this card.
Refs: #5389 (the container descent), #4971 (the stranded-prescription shape), #5364 (this envelope), #8660 / PR #8782 (where it was found).
Generated by Claude Code