Skip to content

test(spec): pin the union-branch projection refusal for a mark nested in a surviving union branch - #17804

Merged
os-bill merged 1 commit into
mainfrom
claude/issue-17107-union-branch-nested-mark-pin
Sep 12, 2026
Merged

test(spec): pin the union-branch projection refusal for a mark nested in a surviving union branch#17804
os-bill merged 1 commit into
mainfrom
claude/issue-17107-union-branch-nested-mark-pin

Conversation

@os-bill

@os-bill os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Part of #17107 — F2 only. F3 (x-unprojectable-branches has 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.

  • Clause-②: no — this PR puts no new key on any published payload.

The defect

projectByPruningUnionBranches refuses a projection when a marked (unprojectable) node survives anywhere but as a direct member of an anyOf / oneOf. The suite pinned that clause only at the root. Loosening the guard from findSurvivingMark(schema) !== null to findSurvivingMark(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:

mutation result
M2 — projection disabled 8 fail
M3 — strict re-conversion removed 4 fail
M1 — guard deleted 1 fail
M1b — guard loosened to === '#' 19/19 PASS

Why the existing pins could not see it — measured, not assumed

Every other "refuses outside a union" assertion returns null through the pruned.length === 0 early-out, never through the guard:

pin branches pruned surviving mark refused by
z.object({ handler: z.function() }) 0 #/properties/handler early-out
z.record(z.string(), z.function()) 0 #/additionalProperties early-out
z.array(z.date()) 0 #/items early-out
PersistenceAdapterSchema 0 #/properties/load early-out
z.union([z.date(), z.function()]) 2 # guard, but at the ROOT

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:

shape branches pruned surviving mark
z.union([z.function(), z.object({ handler, name }), z.string()]) 1 (#/anyOf/0) #/anyOf/0/properties/handler
Automation.FlowFunctionEntrySchema (the real export) 1 #/anyOf/0/properties/handler

Both halves are asserted directly in the test, in both io directions, 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 the expect(pruned).toEqual(...) line reds first.

Discrimination proof

Test-only change, committed first, then the guard mutated on disk under a trap:

== M1b == HEAD blob 1b23049dcf39… ; pre-mutation blob 1b23049dcf39… (equal)
mutation M1b: anchor count 1 -> 0 ; replacement count 0 -> 1
== M1b == post-mutation blob 89c23453fc0d… (differs) => LANDED ON DISK
     × refuses a marked node NESTED inside a SURVIVING union branch
     × leaves Automation.FlowFunctionEntrySchema skipped, marker and all
 Test Files  1 failed (1)
      Tests  2 failed | 19 passed (21)
== M1b == restored blob 1b23049dcf39… ; HEAD blob 1b23049dcf39… ; git diff HEAD bytes: 0
== M1b == RESTORE PROVEN byte-identical

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:schema run twice in the same tree, the one changed file swapped to its base content in between:

run files under json-schema/ manifest sha256
this branch 1525 9675d5f4c959d63a
base content of the changed file 1525 9675d5f4c959d63a

Byte-identical, and git status reports 0 checked-in generated artifacts moved.

The instrument is lit. Under M1b the same measurement moves: 1529 files, manifest 472d131ac42de3ee, and gen:schema exits 1 naming five ledger entries that now emit —

❌ 5 ledger entry(ies) in unemitted-schemas.baseline.json now EMIT a JSON Schema:
     - Automation.FlowFunctionEntrySchema
     - System.EnvironmentArtifactSchema
     - UI.ViewMetadataSchema
     - UI.AssembledViewArtifactSchema
     - UI.FieldWidgetPropsSchema

all five written to disk carrying x-os-unprojectable. The card named one export; the measured population is five.

Changeset: skip-changeset, measured

packages/spec's files[] is dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.jsonscripts/ is not in it.

probe occurrences in the published set
the changed text (subject) 0 (2 in the working tree, so the probe is aimed)
positive control FlowFunctionEntrySchema 62 across dist/
negative control (another scripts/ test's text) 0 (1 in the working tree)

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 sibling objectui checkout at 3fbdd4a2d.

probe this repo objectui
x-unprojectable-branches 3 — the changeset prose, one comment, one write site (build-schemas.ts:494). Zero reads. 0
control x-schema-count read at five bracket sites in build-schemas-check-mode.test.ts, plus a doc page
control: json-schema as a consumed surface 69

The probe finds readers where they exist, in both spaces. build-docs.ts reads no x- annotation off the artifact at all, so the reference pages cannot say a branch was dropped. json-schema is in files[], so the marker is genuinely published to npm with no consumer anywhere.

Same class, not in the card: x-io also has zero readers — one write site (build-schemas.ts:485) and one prose mention, which is the union-branch-projection.ts docblock asserting that "the x-io flag already tells a reader which shape they are looking at". Nothing reads it either. Left for F3.

Verification

check result
vitest run scripts/union-branch-projection.test.ts VERDICT command-exit 0 — 21 passed (21)
pnpm --filter @objectstack/spec test VERDICT command-exit 0 — 473 files / 13439 tests passed
pnpm --filter @objectstack/spec typecheck VERDICT command-exit 0
pnpm --filter '@objectstack/spec^...' build + spec build VERDICT command-exit 0
pnpm lint (whole repo, eslint . --no-inline-config) exit 0, 2m45s — full population, no narrowing
derived gate families (dispatch-gates.mjs --commands, 57 commands) 54 exit 0
control-character self-scan on the changed file no hits

The typecheck green is measured to cover the changed file: tsconfig.test.json includes only src/**/*, so the file is compiled by tsconfig.scripts.json instead — confirmed present with tsc --listFiles, and typecheck runs check: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 no dist/), which CI builds before running them. None reads the changed file.

验收备注

Noted, not filed — each names its successor:

  1. Under a loosened guard the five poisoned artifacts are written to disk before the ledger adjudication rejects the build, so the gitignored 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 publish FlowFunctionEntry with an x-os-unprojectable marker leak; x-unprojectable-branches has no reader #17107, which already owns the emission-site question.
  2. The repaired ratchet'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 publish FlowFunctionEntry with an x-os-unprojectable marker leak; x-unprojectable-branches has no reader #17107.
  3. x-io has 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 publish FlowFunctionEntry with an x-os-unprojectable marker leak; x-unprojectable-branches has no reader #17107.

No card filed: every observation lands inside #17107's own still-open F3.


Generated by Claude Code

`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
@os-bill os-bill added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 12, 2026 — with Claude
@github-actions

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 482d34d60c1d7bdc808c09a9d4edd152443bb101packageMentionDocs.

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

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants