test(spec): pin the union-branch projection refusal for a mark nested in a surviving union branch - #17804
Merged
Conversation
`projectByPruningUnionBranches` refuses a projection when a marked
(unprojectable) node survives anywhere but as a direct member of an
`anyOf` / `oneOf`. The suite pinned that only at the ROOT: loosening the
guard from `findSurvivingMark(schema) !== null` to `=== '#'` passed
19/19, so the suite could not tell the strong guard from the weak one.
Measured why. The four existing "refuses outside a union" assertions —
`z.object({ handler })`, `z.record`, `z.array(z.date())` and
`PersistenceAdapterSchema` — all prune ZERO branches, because none of
those schemas contains a union at all, so each returns null through the
`pruned.length === 0` early-out and never consults the guard. The one
assertion that does reach it carries its mark at the root, which a
root-only guard still refuses.
Adds the shape that discriminates: a union whose first member is dropped
(so the early-out cannot fire) and whose SURVIVING member carries a
marked node in a property position (so the mark sits below the root).
Both halves are asserted directly, so the refusal is attributable to the
guard and to nothing else. Pinned twice — once minimally, once on
`Automation.FlowFunctionEntrySchema`, the real export this holds back,
whose pruned-but-unrefused projection still carries the marker.
Test-only; the guard itself is unchanged and the published artifact is
byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Contributor
📓 Docs Drift CheckNothing 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
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This was referenced Sep 12, 2026
os-bill
marked this pull request as ready for review
September 12, 2026 06:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #17107 — F2 only. F3 (
x-unprojectable-brancheshas no reader) stays open by design: triage is explicit that removing a published field is a separate question from pinning a guard. F3 is measured below, not resolved.The defect
projectByPruningUnionBranchesrefuses a projection when a marked (unprojectable) node survives anywhere but as a direct member of ananyOf/oneOf. The suite pinned that clause only at the root. Loosening the guard fromfindSurvivingMark(schema) !== nulltofindSurvivingMark(schema) === '#'passed 19/19 — the suite could not tell the strong guard from the weak one.Mutation table, re-run on this branch's own base (
8fa3fe63d9), not inherited:=== '#'Why the existing pins could not see it — measured, not assumed
Every other "refuses outside a union" assertion returns
nullthrough thepruned.length === 0early-out, never through the guard:z.object({ handler: z.function() })#/properties/handlerz.record(z.string(), z.function())#/additionalPropertiesz.array(z.date())#/itemsPersistenceAdapterSchema#/properties/loadz.union([z.date(), z.function()])#None of those schemas contains a union at all, so the guard is never consulted; the one that reaches it carries its mark at the root, which a root-only guard still refuses. That is the whole of the 19.
The pins this adds
A shape that defeats both escapes at once — one branch really is dropped, and the surviving mark sits below the root:
z.union([z.function(), z.object({ handler, name }), z.string()])#/anyOf/0)#/anyOf/0/properties/handlerAutomation.FlowFunctionEntrySchema(the real export)#/anyOf/0/properties/handlerBoth halves are asserted directly in the test, in both
iodirections, so the refusal is attributable to the guard and to nothing else. Non-vacuity is a pinned fact, not a claim: if the early-out ever starts firing for these shapes theexpect(pruned).toEqual(...)line reds first.Discrimination proof
Test-only change, committed first, then the guard mutated on disk under a trap:
The 19 pre-existing assertions stay green under M1b — the two new ones are the only discriminator. Restored, the suite is 21/21.
The value the first new pin receives under M1b is the marker leak itself:
{"anyOf":[{"type":"object","properties":{"handler":{"x-os-unprojectable":"function"}, "name":{"type":"string"}},"required":["handler","name"],"additionalProperties":false}, {"type":"string"}]}Does the published artifact move? No — regenerated and diffed
gen:schemarun twice in the same tree, the one changed file swapped to its base content in between:json-schema/9675d5f4c959d63a9675d5f4c959d63aByte-identical, and
git statusreports 0 checked-in generated artifacts moved.The instrument is lit. Under M1b the same measurement moves: 1529 files, manifest
472d131ac42de3ee, andgen:schemaexits 1 naming five ledger entries that now emit —all five written to disk carrying
x-os-unprojectable. The card named one export; the measured population is five.Changeset:
skip-changeset, measuredpackages/spec'sfiles[]isdist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json—scripts/is not in it.FlowFunctionEntrySchemadist/scripts/test's text)And
json-schema/, which is published, is byte-identical above. Nothing released moves.F3 — measured, not resolved
Space searched: every tracked file in this repo at
dcea991f12, and the siblingobjectuicheckout at3fbdd4a2d.x-unprojectable-branchesbuild-schemas.ts:494). Zero reads.x-schema-countbuild-schemas-check-mode.test.ts, plus a doc pagejson-schemaas a consumed surfaceThe probe finds readers where they exist, in both spaces.
build-docs.tsreads nox-annotation off the artifact at all, so the reference pages cannot say a branch was dropped.json-schemais infiles[], so the marker is genuinely published to npm with no consumer anywhere.Same class, not in the card:
x-ioalso has zero readers — one write site (build-schemas.ts:485) and one prose mention, which is theunion-branch-projection.tsdocblock asserting that "thex-ioflag already tells a reader which shape they are looking at". Nothing reads it either. Left for F3.Verification
vitest run scripts/union-branch-projection.test.tsVERDICT command-exit 0— 21 passed (21)pnpm --filter @objectstack/spec testVERDICT command-exit 0— 473 files / 13439 tests passedpnpm --filter @objectstack/spec typecheckVERDICT command-exit 0pnpm --filter '@objectstack/spec^...' build+ spec buildVERDICT command-exit 0pnpm lint(whole repo,eslint . --no-inline-config)dispatch-gates.mjs --commands, 57 commands)The
typecheckgreen is measured to cover the changed file:tsconfig.test.jsonincludes onlysrc/**/*, so the file is compiled bytsconfig.scripts.jsoninstead — confirmed present withtsc --listFiles, andtypecheckrunscheck:scripts-typecheck.Declared narrowing. Three of the 57 derived gates exited 3 = PREREQUISITE NOT MET and are recorded as NOT MEASURED, neither pass nor fail:
check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt. Each reads built output for the whole workspace (80-plus packages with nodist/), which CI builds before running them. None reads the changed file.验收备注
Noted, not filed — each names its successor:
json-schema/tree stays poisoned after a build that exited 1. I hit this and had to regenerate. Successor: F3 on [finding] union-branch projection (PR #17085): the 「refuse a marked node anywhere but a direct anyOf/oneOf member」 clause is pinned only at the root — loosening the guard to root-only passes 19/19 and would publishFlowFunctionEntrywith anx-os-unprojectablemarker leak;x-unprojectable-brancheshas no reader #17107, which already owns the emission-site question.repairedratchet's own text under that failure reads "Good news, and the line has to go with it — in this same PR", i.e. it prescribes deleting the five ledger lines — which, when the emission is a marker leak, is the wrong remedy. It is only reachable through an already-broken guard, which is what this PR pins. Successor: F3 on [finding] union-branch projection (PR #17085): the 「refuse a marked node anywhere but a direct anyOf/oneOf member」 clause is pinned only at the root — loosening the guard to root-only passes 19/19 and would publishFlowFunctionEntrywith anx-os-unprojectablemarker leak;x-unprojectable-brancheshas no reader #17107.x-iohas zero readers (above). Successor: F3 on [finding] union-branch projection (PR #17085): the 「refuse a marked node anywhere but a direct anyOf/oneOf member」 clause is pinned only at the root — loosening the guard to root-only passes 19/19 and would publishFlowFunctionEntrywith anx-os-unprojectablemarker leak;x-unprojectable-brancheshas no reader #17107.No card filed: every observation lands inside #17107's own still-open F3.
Generated by Claude Code