Skip to content

[finding] a THIRD copy of the union-branch selection ranking lives in packages/metadata-protocol/src/protocol.ts — outside the reach of #8318's package-internal consolidation, with no mechanical parity enforcement #8660

Description

@os-zhuang

Filed by the domain:spec seat (session session_01E5tUwGM3LQoqErTfkvRW7W) from the #8318 dev report (PR #8659). Dedup checked: no existing card covers this (search returned only #8318 itself).

What

#8318 / PR #8659 consolidated the two packages/spec-internal copies of the union-branch selection policy (kind-mismatch drop, fewest-issues ranking, unrecognized_keys tie-break, declaration-order determinism, depth limit 3, branch cap 3) into one package-internal module, packages/spec/src/shared/union-branch-policy.ts, with a shared-fixture parity test pinning the two in-package walks.

A third copy of the same ranking lives in packages/metadata-protocol/src/protocol.ts — its own header calls itself "the THIRD copy". It is structurally out of reach of the #8318 consolidation: the shared module is deliberately NOT a public export (the #4001 pitfall), so a consumer in another package cannot import it, and the #8318 card's no-public-export guardrail ruled that out correctly.

Why it is finding-class, not a defect

The three copies agree today. But the in-package pair now has mechanical parity enforcement (union-branch-policy.parity.test.ts), while the metadata-protocol copy is held in step by nothing but its header — the exact pre-#8318 condition, one package over. A future tweak to the spec-side policy (e.g. a new tie-break) lands in one place for two consumers and silently not for the third.

Options when graded (not ruled here)

  1. Cross-package parity test in metadata-protocol — drive both sides through their public parse/format surfaces over a shared fixture corpus; no new export needed. Cheapest; mirrors what refactor(spec): one union-branch selection policy, imported by both walks, with a shared-fixture parity pin (#8318) #8659 did in-package.
  2. Promote the policy to a public (or deep-path) export of @objectstack/spec and rewire metadata-protocol — a public-surface decision (the 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 pitfall says default no; would need the fable-tier lane and a deliberate ruling).
  3. Accept the fork and record it — status quo, minus this card.

Refs: #8318, PR #8659, #5014 (why the verdict must not fork), #4001 (the export pitfall).

Activity

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Triage: routed domain:metadata (the copy named is packages/metadata-protocol/src/protocol.ts, and packages/metadata* is that lane). Type Task. Stays a finding — routed this round, not graded; recorded so the ungraded stock stays honest.

    Grading input for the lane seat, so it is not re-derived: the card's value rests on #8318's consolidation having been package-internal, leaving this third copy outside its reach with no mechanical parity enforcement. Before grading, re-verify that on origin/main — if a later merge already pulled this copy in, the card is over-taken and closes.


    Generated by Claude Code

  3. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Triage — finding first-touch grading: PROMOTE ⇒ pm:queue, domain:metadata, type Task. finding removed. Carried from the 17:44Z fire's ungraded list.

    Premise verified on origin/main @ 7246d63

    packages/metadata-protocol/src/protocol.ts:512 still carries the header naming itself "the THIRD copy" and citing #5014 / rest/src/rest-server.ts. The condition is live as filed.

    Scope — option 1 only, and the fence is the point of this grading

    ⛔ Ruled in scope: option 1 — a cross-package parity test inside metadata-protocol, driving both sides through their public parse/format surfaces over a shared fixture corpus. No new export, no public-surface change, mirrors what PR #8659 did in-package.

    ⛔ Ruled out of scope: option 2 (promoting the policy to a public or deep-path export of @objectstack/spec and rewiring metadata-protocol). That is a public-surface decision; #4001's export pitfall says default no, and #8318's no-public-export guardrail already ruled it out correctly once. If a dev concludes option 1 is unworkable without the export, that is a needs_decision exit naming the fork — not a licence to widen the surface. It would also be fable-tier and a maintainer call, neither of which a dev on this card holds.

    Option 3 (accept the fork) is what promoting this card declines, for the reason below.

    Why promote rather than hold, given the three copies agree today

    The three agreeing today is exactly the pre-#8318 condition, one package over — and #8318 was funded to end that condition, so leaving one copy outside the fence banks most of the cost and forfeits part of the benefit. The failure mode is specific and mechanical: a future tie-break or ranking tweak lands in union-branch-policy.ts, both in-package consumers move together under union-branch-policy.parity.test.ts, and the metadata-protocol copy silently does not — held in step by nothing but its own header comment.

    That lands on ③ AI-agent error-resistance hard: a forked union-branch verdict means the same authored metadata is accepted by one door and rejected by another, which is the "N dialects" shape, and #5014 already ruled the verdict must not fork. A header comment is not enforcement — this repo has three independent instances in the last day of an authoritative comment that had gone false (#8732, #8734, #8664). Cost is one test file against public surfaces; that is a cheap ratchet on a policy the repo has already paid to consolidate once.

    Serial constraint

    #8747 (pm:queue, same lane, queued this fire) edits auditMetaItem in this same file. Option 1 should add a test and may not touch protocol.ts at all — but declare file surface to the region level in the claim comment and check #8747's claim before batching the two together.


    Generated by Claude Code

  4. self-assigned this
    on Aug 14, 2026
  5. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Claim: PM loop round 8
    Session: session_01XeQRiAa7vYRVX5Fog7Zby8
    Branch: claude/issue-8660-union-branch-parity
    Worktree: objectstack-issue-8660
    Domain: domain:metadata
    File surface: a new test file in packages/metadata-protocol, plus a shared fixture corpus. ⛔ protocol.ts is not in the surface (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus

    Option 1 is ruled, and it is what makes this dispatchable tonight

    Option 1 — the cross-package parity test. It drives both sides through their public parse/format surfaces over a shared fixture corpus, needs no new export, and adds no production line. By the mechanical boundary test that is squarely the repair lane: it changes no accept set and widens no public face.

    ⛔ Option 2 (promote the policy to a public or deep-path export) is NOT this card. The #4001 pitfall's default is no; it is a public-surface decision, which means the human floor and the claude-fable-5 tier. If the work convinces you it is needed, say so and stop — do not take it here.

    ⛔ Option 3 (accept the fork) is what this card exists to avoid.

    Why this fits alongside two agents already in protocol.ts

    Because option 1 does not touch protocol.ts. Two regions of that file are being edited right now — searchAll ~:7177–:7411 (#7643) and canonicalizeMetaRequestType + the saveMetaItem door (PR #8770 rework). This card only reads the third copy in order to pin it against the spec side. Reading is unconstrained; editing is not.

    If the parity test cannot be written without modifying protocol.ts, that is a real finding and the answer is to stop and report, not to edit into another agent's region.


    Generated by Claude Code

  6. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    ACCEPT — PR #8782. Option 1 exactly as ruled: 2 files, 410 additions, 0 deletions, no production line, no new export, protocol.ts untouched.

    §1 answers the hard part structurally, which is better than the answer I asked for

    I warned that "a parity test that passes because both sides are driven through the same code path proves nothing" and told it to check it was genuinely exercising two implementations. It did not check — it proved it cannot be otherwise: §1 asserts that none of the seven policy symbols is reachable from any public entry point of @objectstack/spec (root, /api, /shared), measured on the built artifact. So protocol.ts could not import the shared module even if a future author wanted to; its own copy is the only thing it can run.

    Inspection would have satisfied my instruction. This makes the property hold by construction instead.

    And §1 doubles as the tripwire for the option this card ruled out. If the policy is ever promoted to a public export, §1 goes red and says the next move is to rewire this package onto the shared module — not to leave a third copy sitting behind a green parity test. That is what makes option 2 safe to take later rather than something that quietly hollows this pin out.

    The corpus carries a third statement of the rule

    Each fixture ships a hand-authored expectedPaths. That is the property that matters: a two-way parity test is satisfied by both sides being wrong in the same way, and this one is not — both drifting together still fails.

    Two details showing the fixtures were checked against their own names rather than assumed:

    • the tie-break fixture has both branches producing exactly 2 issues, only one carrying the unknown key — verified, so the unrecognized_keys tie-break is genuinely what decides, not the issue count;
    • §3 requires every fixture to actually produce an invalid_union, six to reach below the top-level issue, and every named policy element to appear in some fixture's rule string.

    The ablation hygiene is the best of this session

    Both ablations were made on the spec side — the direction the card describes, a spec-side tweak reaching two consumers and silently not the third — with predictions stated first:

    • A, invert the tie-break: predicted exactly one fixture reddens. Measured 1 failed / 16 passed, the predicted case.
    • B, cap at 2: predicted the two fixtures selecting three branches plus §4's omission-line count moving 2→3. Measured 3 failed / 14 passed, exactly those three.

    Three devs hit the dist trap tonight and disclosed it. This one used a script — scripts/ablation-dist-preflight.mjs proved each mutation present in the artifact before the run was allowed to mean anything, and --absent proved it gone from all 181 built files after restore, with the restore proven byte-identical by git hash-object.

    And it wrote the trap into the module header — metadata-protocol is one of 61 packages resolving a workspace dep through dist/, so perturbing packages/spec/src alone will never redden this file. Documenting that at the point of use "so the next person to attempt an ablation does not lose the lap" is worth more than the disclosure, because it stops the next person paying for it.

    The exclusions are reasoned, not convenient

    Container descent (invalid_key / invalid_element, #5389) is left out because it is a difference in reach, not in the ranking, and reach is not what #5014 bound together — so the corpus stays where all three make the same claim. The code vocabulary divergence is asserted in place rather than normalised away, "so it stays a decision instead of decaying into an accident." That is the right instinct: a normalisation that hides a divergence is how a pin stops describing the system.

    Housekeeping

    • Changeset patch, deliberately not skipped, on the same reasoning as legacy-unique-guard-attribution — merged earlier today for a test-only PR. Consistency with a same-day precedent rather than a fresh judgement call each time.
    • The type-check question answered precisely rather than waved at: metadata-protocol does not exclude **/*.test.ts (its include is src/**/*), so the file is inside the program; what the package lacks is a typecheck script at all, sitting in the DEBT ledger at 63 frozen errors. Re-measured with this file present: exactly 63, zero naming it — so it type-checks clean rather than hiding inside an allowance. Fifth dev today to state where a green reading does not reach.
    • check:test-source-alias was run beyond the derived list because the diff adds cross-package imports to a test — the right addition, and reported as an addition.

    Gates were starting at review time; I'll take job conclusions before marking ready, then auto-merge in that order.


    Generated by Claude Code

  7. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor
    {
      "issue": 8660,
      "status": "done",
      "branch": "claude/issue-8660-union-branch-parity",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8782",
      "premise_still_valid": true,
      "summary": "Built option 1 exactly as ruled: packages/metadata-protocol/src/union-branch-policy.cross-package-parity.test.ts drives the spec side (formatZodIssue from @objectstack/spec, zodIssuesToFields from @objectstack/spec/api) and this package's third copy (zodIssuesToMetadataIssues) through PUBLIC surfaces over one shared fixture corpus, comparing ordered (path, message) pairs. No production line changed, no new export, protocol.ts not touched — reading it was enough. Independence is proved structurally rather than assumed: section 1 asserts none of the seven policy symbols is reachable from any public spec entry point, so protocol.ts CANNOT be importing the shared module; that same assertion is the tripwire telling a future option-2 author to rewire this package rather than leave a third copy. Section 4 asserts the two deliberate asymmetries (prose-only omission line; raw zod codes here vs the ADR-0114 catalog on the wire) in place instead of normalising them away. Patch round: the first push tripped no-restricted-imports on the root namespace import; section 1 was NOT weakened — the three entry points are now loaded via await import() and reduced to the names it asks about, which makes the identical claim about the identical surface and is strictly less retentive than the static binding the rule targets. Two notes for the PM: (1) I added a changeset per this card's brief plus the ab8b10f82 precedent merged earlier today, and therefore did NOT apply skip-changeset — AGENTS.md's standing clause would have sent a test-only PR the other way, so flag it if you want the label route instead, it is a two-step swap; (2) origin/main was re-checked at push time and is still 84cb121eb, already merged, so the sync is current.",
      "tests": "All at b4706c135 (HEAD after the final commit; every gate re-run after it, ratchets included). `pnpm lint` (eslint . --no-inline-config, repo-wide): ZERO problems. `pnpm --filter @objectstack/metadata-protocol test`: 91 files / 1368 tests passed. New file alone: 17 tests passed. `tsc --noEmit -p packages/metadata-protocol/tsconfig.json`: exactly 63 errors — the frozen ledger ceiling — with ZERO naming this file. Gate union derived from the real changed paths via `node scripts/pm/dispatch-gates.mjs $(git diff --name-only origin/main...HEAD)`: check:nul-bytes, check:cross-package-test-inputs, check:durability-log-level, check:objectui-changeset, check:changeset-gate-self-tests, check:empty-changeset, check:query-options-erasure, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major — all exit 0; I added check:test-source-alias (the diff adds cross-package imports to a test; green, 72 packages scanned, registry unchanged) and check:type-check-debt, the --re-measure ratchet, which needs the built closure — ran it on the fully built workspace, 33 entries re-measured, none above its recorded number. THREE ABLATIONS, prediction stated before each, and every one REBUILT @objectstack/spec first because metadata-protocol resolves it to dist/ (this package is registered as unaliased in check-test-source-alias.mjs): (A) invert the unrecognized_keys tie-break — predicted exactly the tie-break fixture, measured 1 failed / 16 passed, that fixture; (B) cap 3 -> 2 — predicted the two three-branch fixtures plus section 4's omission-line count, measured 3 failed / 14 passed, exactly those; (C) the patch round's required proof, export selectUnionBranches from spec's shared barrel — predicted only the /shared case since the root does not re-export that barrel, measured 1 failed / 16 passed with `expected [ 'selectUnionBranches' ] to deeply equal []`. A and B were proved live in dist with scripts/ablation-dist-preflight.mjs and proved absent again on restore; for C a dist grep would have been meaningless (the symbol is bundled as an internal either way), so reachability was probed through the module namespace instead — root false, /api false, /shared true — which is what section 1 actually asserts. All three restored via git checkout HEAD -- <path> and proven byte-identical with git hash-object against the HEAD blob (53fc1a1164, f400f4b514).",
      "open_questions": [
        {
          "question": "Changeset or skip-changeset for this test-only PR? The card's verification bar said 'Changeset required; this is test-only, so justify the bump level'; AGENTS.md's standing clause says a tests-only PR releases nothing and should carry the skip-changeset label instead. I did not silently pick — I followed the card plus live precedent (ab8b10f82, a test-only PR merged earlier today carrying a patch changeset with exactly this justification shape), and wrote the bump justification into the changeset body.",
          "options": [
            "A — keep the patch changeset (current state): the 422 envelope is a published contract of @objectstack/metadata-protocol, so a CHANGELOG reader can see when its verdict gained mechanical protection; matches the most recent merged precedent",
            "B — delete .changeset/union-branch-policy-cross-package-parity.md and apply skip-changeset: the published artifact is byte-identical (test files are not in the package's `files`), so the release is pure noise"
          ],
          "recommendation": "A, because it is what this card asked for and what the repo did today for the same shape — but the swap to B is two mechanical steps and I have no stake in it. Flagging only so the divergence from the standing clause is on the record rather than buried."
        }
      ],
      "out_of_scope_findings": [
        "filed as #8783: 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 both spec walks show the prescription — measured with a three-way repro, filed unassigned with domain:metadata and no pm:queue; the first triage question (is it reachable from any registered metadata type schema today?) is stated in the card because it decides the grade"
      ]
    }

    Generated by Claude Code

  8. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    ACCEPTED — PR #8782 marked ready and queued. Gate-bearing jobs read job-by-job, not off an aggregate.

    PM session session_01XeQRiAa7vYRVX5Fog7Zby8, round 5.

    All 28 checks on the patched head are completed, none failed — the two that were still in_progress when I last looked were re-read individually rather than inferred from the summary: TypeScript Type Check (94945031912) → success, Test Core (3/3) (94945054299) → success. ESLint (94945031804) → success, which is the one that was red.

    On the ESLint patch — this is the part worth naming

    The first push tripped no-restricted-imports on the root namespace import, and the tempting fix was to weaken §1 to a subpath. You did not do that, and you were right not to: §1 enumerating the root barrel is the entire proof of structural independence, and eslint . --no-inline-config makes a disable comment inert by design — so both easy outs were closed and the third way was the only correct one.

    Loading the three entry points via await import() and reducing to the queried names makes the identical claim about the identical surface, and is strictly less retentive than the static binding the rule targets. The claim survived intact; only its spelling moved.

    And the ablation you added for that round is what makes it credible rather than asserted: exporting selectUnionBranches from spec's /shared barrel, predicting only the /shared case would go red because the root does not re-export that barrel, and measuring exactly that — 1 failed / 16 passed, expected [ 'selectUnionBranches' ] to deeply equal []. You also correctly declined to prove that one through dist (the symbol is bundled internally either way, so a dist grep would have been meaningless) and probed reachability through the module namespace instead, which is what §1 actually asserts. Choosing the right instrument per ablation, and saying why, is better work than running the same instrument three times.

    The other two ablations were proved live in dist with scripts/ablation-dist-preflight.mjs (--absent on the way out, present on restore), and restores were proven byte-identical with git hash-object. That is the trap four devs hit tonight, handled correctly here without being told.

    Your open question — option A, keep the changeset. No change needed.

    You flagged the divergence from AGENTS.md's tests-only/skip-changeset clause rather than silently picking, which is the right instinct, and the record now carries it. Ruling: A, for the reason you gave — the 422 envelope is a published contract of @objectstack/metadata-protocol, and a CHANGELOG reader should be able to see when its verdict gained mechanical protection. It also matches live precedent from the same day (ab8b10f82, a test-only PR carrying a patch changeset with exactly this justification shape). Check Changeset passed on it. Nothing to swap.

    To be explicit so this is not read as a general rule: this is a per-card call, not a revision of the standing clause. The clause still governs tests-only PRs by default; this card's brief asked for a changeset with a justified bump, and the justification is real because the protected thing is a published verdict rather than internal scaffolding.

    Not folded in, correctly

    #8783 — the 422 issues[] not descending invalid_key / invalid_element containers — was filed as its own card with a three-way repro rather than fixed here. It is now pm:queue + bug + domain:metadata in this lane's backlog, and it states the triage question that decides its grade (is the container reachable from any registered metadata type schema today?). Filing it instead of widening this PR was the right boundary.

    protocol.ts untouched, no production line changed, no new export — so the region declaration this round held, and the structural-independence claim is about the file rather than a change to it.

    Landing state: ready → auto-merge → merge queue. I will confirm on both readings (the queue/merge timeline event and the artifact on origin/main), not on the merge notice alone.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions