Skip to content

finding(gate): check:spec-symbols examines only the FIRST occurrence of each claim phrase per docblock, so an innocent early "mirror"/"match" hides a real claim later in the same block #8819

Closed

Description

@os-justin

Surfaced while repairing the check:spec-symbols red on PR #8164 (objectui#7727). ⛔ Not claimed, not assigned. ⛔ Not a claim that any current verdict is wrong — the gate caught the thing it was pointed at. This is about what it structurally cannot see.

The mechanism

scripts/check-spec-symbol-derivation.mjs, findClaim:

for (const pattern of CLAIM_PATTERNS) {
  const hit = pattern.exec(text);
  if (!hit) continue;
  const start = hit.index;
  const end = hit.index + hit[0].length;
  for (const at of mentions) {
    const distance = at >= end ? at - end : start - (at + SPEC_MENTION.length);
    if (distance < 0 || distance > CLAIM_WINDOW) continue;
    …

Every entry in CLAIM_PATTERNS carries /i and none carries /g, and exec is called once per pattern. So exactly one occurrence of each phrase — the first in the block — is ever tested for proximity to an @objectstack/spec mention. When that first occurrence fails the CLAIM_WINDOW (60 characters) or the same-sentence test, the loop moves to the next pattern and every later occurrence of the same phrase goes unexamined, including one sitting directly beside a mention.

Why it bites in practice, and not theoretically

Two of the twelve patterns are ordinary English that appears in technical prose constantly:

/\bmirror(?:s|ed|ing)?\b/i,
/\bmatch(?:es|ing)?\b/i,

The docblock this was found in is itself an instance. ROW_PREDICATE_ROOTS in packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx opens with "They were mirror images" and "a condition that silently never matches", both far from the block's single @objectstack/spec mention. A later sentence in that same block reading, say, "mirrors @objectstack/spec's root vocabulary" would be invisible — masked by the innocent "mirror images" earlier.

The claim that actually failed there was canonical, which happens to be a rare enough word that its first occurrence was the real one. That is luck, not coverage.

Direction of the failure

⚠️ It fails permissive: the gate under-reports. A declaration that hand-copies a spec symbol and says so, in a docblock that happens to use "match" or "mirror" innocently earlier, passes. That is precisely the shape the gate's own failure text is written against:

a canonical-sounding comment with nothing behind it is a planted premise for the next session, not stale documentation

and the cautionary tale it cites — ViewNavigationConfig (objectui#4588), which carried the spec's six navigation keys, drifted on mode, and passed every CI run under the comment "Aligned with @objectstack/spec ListView.navigation".

⛔ What this does NOT claim

  • ⛔ Not a claim that any declaration is currently mis-verdicted. Measuring that means re-running the corpus with an all-occurrences matcher and diffing the verdict sets — that is the work, and it was not done here.
  • ⛔ Not a claim the fix is simply adding /g. Making every occurrence eligible will surface hits that the single-occurrence behaviour has been suppressing since the gate was written, so the real question is what the new population looks like and whether the same-sentence test is strong enough to keep it honest. Its own green verdict line already reads "spec alignment claims: 2 declared deliberate copies, 19 unbacked claims in 5 packages" — so there is an existing reported-but-not-failing tier, and any change here lands on top of it.
  • ⛔ Not a claim about CLAIM_WINDOW = 60 or the same-sentence rule, both of which look deliberate and are documented at the call site with a worked example (ChartDataSeries).

Reproducing it

Read findClaim and CLAIM_PATTERNS in scripts/check-spec-symbol-derivation.mjs. A direct demonstration: take any declaration whose docblock cites @objectstack/spec, put a claim phrase adjacent to the mention, and put an innocent occurrence of the same phrase earlier in the block — the gate stays green; delete the earlier one and it goes red.

Related

PR #8164 / objectui#7727 (where it surfaced) · objectui#4588 and objectui#4592 (the drift the gate exists to catch)

Activity

  1. added
    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
    on Sep 9, 2026
  2. os-try-charles commented on Sep 16, 2026

    @os-try-charles
    Collaborator

    Claim: PM loop round R61
    Session: session_015h79niBMyoB1xcaQje3uiz
    Branch: claude/issue-8819-spec-symbols-first-occurrence-only
    Worktree: objectui-issue-8819
    Domain: domain:devx
    File surface: scripts/check-spec-symbol-derivation.mjs (findClaim / CLAIM_PATTERNS) and scripts/__tests__/check-spec-symbol-derivation.test.ts (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgement tier (opus) — quoting this fire's node scripts/pm/dispatch-gates.mjs --tier: "no path-derived mandate: the surface hits none of the 3 declared glob(s)", run from the objectstack checkout (objectui ships no such script; the tier half is glob policy and applies cross-repo). Default chosen because the card carries design judgement.
    Clause-②: no
    Thread-read: 5695732849
    Serial constraints cleared: Both files — 0 of 15 open PRs hold either.


    Seat: domain:devx @ objectui, session_015h79niBMyoB1xcaQje3uiz, R61, 2026-09-16T10:29Z. Lock depth read before claiming: state: lock is free, queue: empty.

    ⭐ Scope comes from the triage comment 5695732849, ⛔ not from the card body, which predates it — on this card the body is the single most out-of-date thing on the page.

    Serial re-measure taken at claim time, ⛔ not from the seat post's §3 table. GET /pulls/{n}/files fully paginated over all 15 open PRs — 1,774 filenames examined (control). Positive control, same instrument, same population: scripts/pm/check-half-states.mjs → PR #9391 ⇒ the membership test discriminates, so the zeros below are readings.

    Clause-② = no — judged from card CONTENT, not paths. This card neither relaxes an acceptance set nor widens a published surface: it touches no packages/spec/**, adds no error code, and changes no published package's bytes. ⚠️ Tightening a gate toward its already-declared subject is ⛔ not clause ②.


    Generated by Claude Code

  3. os-try-charles commented on Sep 16, 2026

    @os-try-charles
    Collaborator

    os-dev-report

    {
      "issue": 8819,
      "status": "done",
      "branch": "claude/issue-8819-spec-symbols-first-occurrence-only",
      "pr": "https://github.com/objectstack-ai/objectui/pull/9608",
      "session": "session_015h79niBMyoB1xcaQje3uiz",
      "premise_still_valid": true,
      "head": "140fb2b6a",
      "base": "f0f4d6c8e632d050457e8ae91e38c3ddd68bc597",
      "docblocks_newly_failing_under_fixed_gate": 0,
      "blast_radius": {
        "method": "both scanners run over the SAME file list with the SAME spec export map (specExportNames(), 5085 names); verdict sets keyed file:line symbol (claim phrase) and diffed",
        "population": "2322 files — a deliberate superset of the gate's own 1384, the probe omitting the gate's .test./.stories. exclusion",
        "before": 20,
        "after": 20,
        "added": 0,
        "removed": 0,
        "gate_verdict_line_before_equals_after": true,
        "instrument_discriminates": "yes — the new masked fixture is null under the pre-fix findClaim and {phrase: mirrors, distance: 1, symbols: [ListView]} under the fixed one; the same block with the innocent early occurrence deleted is flagged by BOTH",
        "reading": "the masking CONDITION is demonstrated; an actually ESCAPED claim on this tree is measured at ZERO — which is the reading the triage comment named as grounds for resizing to p3, not a reversal"
      },
      "premise_checks": {
        "zone2_1_shared_regex_state": "CONFIRMED HAZARD, avoided. CLAIM_PATTERNS is a shared, EXPORTED module-level array; /g on those literals would make any importer's exec/test resume mid-string. Fix uses module-private /g COPIES with matchAll (clones via species constructor, never mutates the source lastIndex) — the idiom MEMBER_CITATION already uses in this same file. A new pin asserts CLAIM_PATTERNS stays /g-free; determinism and scan-order-independence are pinned as readings, not assumed.",
        "zone2_2_blast_radius": "MEASURED: 0 newly failing. No mass docblock edit opens; nothing was repaired.",
        "zone2_3_row_predicate_roots": "STILL TRUE on f0f4d6c8e — 'They were mirror images' (line 67), 'a condition that silently never matches' (line 62), a second 'The mirror case' (line 77), and one single @objectstack/spec mention (line 81) far from all three. A live instance of the masking CONDITION; NOT a live escape — no claim phrase sits beside that mention, which is why it is untouched and why the verdict set did not move."
      },
      "gates": {
        "pnpm check:spec-symbols": 0,
        "npx vitest run scripts/__tests__/check-spec-symbol-derivation.test.ts + the 6 spec-symbol-ledger-7265 suites (every importer of the module)": 0,
        "npx vitest run scripts/__tests__/check-spec-symbol-derivation.test.ts (alone, final)": 0,
        "pnpm type-check:scripts": 0,
        "pnpm lint:root": 0,
        "pnpm check:control-bytes": 0,
        "pnpm check:new-line-citations": 0,
        "pnpm check:shell-escape-residue": 0,
        "pnpm check:test-path-roots": 0,
        "pnpm check:changeset-claims": 0,
        "node scripts/check-changeset-presence.mjs": 0,
        "node scripts/check-governed-queue-guard.mjs --test (both changed paths)": 0
      },
      "gate_evidence": {
        "check:spec-symbols": "byte-identical verdict before and after: '1384 files scanned against 5085 spec export names; 18 declared dialects, 0 untriaged collisions in 0 packages' / '2 declared deliberate copies, 18 unbacked claims in 5 packages' / '1384 sources + 184 documentation pages; nothing cites a key its spec symbol does not declare'",
        "vitest": "Test Files 7 passed (7) / Tests 160 passed (160); final single-file leg Tests 74 passed (74)",
        "lint:root": "FULL scope, not narrowed (12s wall) — 0 errors, 32 pre-existing warnings. Per-package turbo lint not owed: diff touches only scripts/, and eslint.config.js enables no type-aware linting (no project / projectService), so this diff cannot move the verdict on any untouched file.",
        "changeset": "check-changeset-presence.mjs own verdict line: 'No source or published contract of a released package changed in this range, so no changeset is owed.' skip-changeset is a phantom here and was NOT applied.",
        "governed_surface": "NOT GOVERNED — 2 paths checked against 5 governed surfaces; none matched. Ordinary review/merge route.",
        "control_bytes": "check:control-bytes exit 0, plus a direct grep -naP over both changed files for the control-byte class: no hits."
      },
      "ablation": {
        "shape": "one-shot, on the COMMITTED fix, trap-restored, absolute paths",
        "mutation_proof_on_disk": "head blob 88006305fd8dd62ecd4828ed0e4352b9104209a0 vs mutated blob eb03a2f45bcab189f8aa3502d495c2a256b7f62e (non-empty, different); grep -c CLAIM_PATTERNS_ALL in the mutated file = 0",
        "red_leg": "Tests 3 failed | 2 passed | 69 skipped (74) — vitest exit 1",
        "green_leg": "Tests 74 passed (74) — vitest exit 0",
        "the_2_that_pass_under_both": "the controls, deliberately: (a) the same block with the innocent early occurrence deleted is flagged by both scanners, so the masking is the ONLY difference; (b) the /g-free pin on CLAIM_PATTERNS holds under both, so it pins the design rather than the change",
        "restore_proof": "git diff HEAD empty AND restored blob hash == HEAD blob hash — not an exit code"
      },
      "line_budget": "not applicable — no skills/ path in the diff. Diff size: 2 files, 149 insertions, 34 deletions; of the 102 changed lines in the .mjs, most are a re-indent of the existing inner loop body under the new occurrence loop plus a 9-line header paragraph. No published bytes moved.",
      "files_changed": [
        "scripts/check-spec-symbol-derivation.mjs",
        "scripts/__tests__/check-spec-symbol-derivation.test.ts"
      ],
      "mcp_calls": "0 — no MCP GitHub tool was called, read or write.",
      "api_writes": "3 REST proxy writes: POST /repos/objectstack-ai/objectui/pulls (415, missing Content-Type), POST /repos/objectstack-ai/objectui/pulls (201, PR 9608 draft), POST /repos/objectstack-ai/objectui/issues/8819/comments (this report). Plus 2 git pushes (git protocol, not REST): the empty-branch routing probe and the fix commit. ZERO label writes.",
      "deviations": [
        "The first POST /pulls returned 415 from the agent proxy: 'Request bodies must declare Content-Type: application/json.' Retried once with the header; nothing was created by the failed attempt.",
        "A first draft of the gate header paragraph contained the literal two-character comment terminator inside the block comment (written as part of a glob spelling a package source path), which closed the docblock early and made the module unparseable — caught by vitest, repaired by rewording, and the module now parses. Recorded because the edit and the census that preceded it were separate runs: the blast-radius census ran on a parseable module, before that header edit.",
        "Verdict-set census probe scanned 2322 files vs the gate's 1384 (it omits the gate's .test./.stories. exclusion). A superset, so the zero-added reading is conservative; declared rather than corrected."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: memberCitationsIn in the same file already walks every occurrence (matchAll over a shared /g MEMBER_CITATION), so rule 4 never carried this defect. The two halves of the file now agree and there is nothing to file. Successor: this PR."
      ],
      "summary": "findClaim now tests EVERY occurrence of each claim phrase per docblock instead of only the first, via matchAll over module-private /g copies — the exported CLAIM_PATTERNS literals stay /g-free so no importer's exec/test can carry lastIndex state and make the gate non-deterministic. CLAIM_PATTERNS, CLAIM_WINDOW and the same-sentence rule are untouched: this tightens toward the declared subject, it does not widen. Blast radius measured over a superset of the gate's corpus: 20 rule-2 findings before, 20 after, 0 added, 0 removed — 0 docblocks newly fail, so no repair work opens. The instrument discriminates (new fixture green under the old scanner, red under the new, with a control that is red under both), so the zero is a reading and not a dead probe: the masking condition is real, an escaped claim on this tree is not.",
      "notes": [
        "PR 9608 carries the label 'tests', applied by a labeler and not by this seat. No needs:contract-review label is present. This seat wrote no labels.",
        "PR body read back after creation and byte-compared against what was sent: identical except a stripped trailing newline; the session-URL attribution footer survived, and the stored body contains zero angle-bracket characters."
      ]
    }

    Generated by Claude Code

  4. os-try-charles commented on Sep 16, 2026

    @os-try-charles
    Collaborator

    ACCEPT — PR objectui#9608. ⛔ NOT readied: CI has not converged.

    domain:devx @ objectui seat, session_015h79niBMyoB1xcaQje3uiz, R61, 2026-09-16T10:45Z. Verified against the tree and GitHub, ⛔ not against the report.

    Path face — get_files

    2 files, +149/−34: scripts/check-spec-symbol-derivation.mjs (+68/−34) · scripts/__tests__/check-spec-symbol-derivation.test.ts (+81/−0). ⇒ NOT GOVERNED, ordinary route.

    ⭐ The hazard I flagged in Zone 2 was real, and the fix avoids it in the file's own idiom

    I warned that the obvious /g fix would make exec/test stateful via lastIndex. Confirmed, and worse than I put it: CLAIM_PATTERNS is exported (:1080), so /g on those literals would leak lastIndex into any importer, making the gate non-deterministic — a strictly worse failure than the bug being fixed.

    My own reading of the landed source:

    reading value
    CLAIM_PATTERNS (:1080) exported, /g-free ✅
    CLAIM_PATTERNS_ALL (:1111) module-private const, .map()ed from it ✅
    findClaim (:1509→:1515) loops the private copies via matchAll ✅
    MEMBER_CITATION (:1776) already used matchAll before this PR
    new pin "⛔ the exported patterns stay /g-free, so exec/test on them cannot carry state" ✅

    ⭐ The last two matter most: the fix adopts an idiom already present in the same file rather than inventing one, and it pins the design so the next author cannot reintroduce the hazard. ⛔ CLAIM_PATTERNS, CLAIM_WINDOW and the same-sentence rule untouched ⇒ tightening toward the declared subject, ⛔ not widening. Clause-② no stands.

    The blast-radius measurement, and why its ZERO is the right answer rather than a disappointing one

    Corpus re-run — triage's item 1, which it flagged as "a dev who lands /g and a green run has measured nothing":

    Before 20 findings, after 20. 0 added, 0 removed. Population 2,322 files — a deliberate superset of the gate's own 1,384 (the probe omits the gate's .test./.stories. exclusion), so the zero is conservative, and that was declared rather than quietly corrected.

    ⭐ The zero is a reading, not a dead probe, and the dev proved it: the new fixture is null under the pre-fix findClaim and a real hit under the fixed one, while a control — the same block with the innocent early occurrence deleted — is flagged by both. ⇒ the masking is the only difference between the two scanners.

    ⚠️ And triage pre-authorised exactly this outcome. I had not read that far before dispatching; its words:

    ⛔ What p2 does NOT assert. The masking condition is demonstrated; an actual escaped claim is NOT MEASURED… If the measurement below comes back empty, resizing to p3 is the correct response, ⛔ not a reversal.

    ⇒ the masking condition is live on this tree (ROW_PREDICATE_ROOTS still carries three masked phrases far from its single mention, re-verified on f0f4d6c8e), but no claim currently escapes. ⛔ I am not re-grading to p3 — priority is triage's and the card closes on merge anyway. Recorded so the reading is not mistaken for a null result.

    Ablation

    Red leg 3 failed / 2 passed, green leg 74 passed, same command. ⭐ The 2 that pass under both are deliberate controls (the deleted-early-occurrence block; the /g-free pin). Mutation proven on disk by blob hash, restore proven by blob-hash equality against HEAD — ⛔ not by an exit code.

    12 gates all exit 0, including check:spec-symbols returning a byte-identical verdict line before and after. mcp_calls: 0 ⇒ admissible.

    Deviations — both acceptable, one worth carrying as a lane fact

    1. ⭐ POST /pulls returned 415 on the first attempt: "Request bodies must declare Content-Type: application/json." ⚠️ This is the local agent proxy, ⛔ not GitHub, and it writes zero bytes while looking like a failed create. The sibling objectstack lane has this recorded (their card #18339, six occurrences across four endpoints); this is the first time this seat has seen it in objectui. Retried with the header ⇒ 201. ⛔ Nothing was created by the failed attempt. Carried to the seat post.
    2. ⚠️ Self-reported, and the honesty is the point: a first draft of the new header paragraph contained a literal comment terminator inside the block comment, closing the docblock early and making the module unparseable. Caught by vitest, repaired. The dev declared that the blast-radius census ran before that edit, on a parseable module. ⛔ That ordering does not invalidate the census — the edit is comment-only — and the final gates (which require the module to parse) all exit 0.

    ⛔ Why not readied

    Lint, Type Check, Doc Snippet Type Check, Skill Example Check and all 4 shards are in_progress at this reading (22 green, 0 red). Entry needs every check green. ⇒ flip + enqueue on the next tick once converged.


    Generated by Claude Code

  5. removed their assignment
    on Sep 16, 2026
  6. os-try-charles commented on Sep 16, 2026

    @os-try-charles
    Collaborator

    ✅ LANDED — PR objectui#9608 merged 2026-09-16T11:20Z. Probe-verified by CONTENT.

    domain:devx @ objectui seat, session_015h79niBMyoB1xcaQje3uiz, R61, 2026-09-16T11:21Z.

    sha subject
    M 253c314184 fix(scripts): check:spec-symbols reads EVERY occurrence of a claim phrase, not the first (#9608)
    M^ 358aff8985 fix(plugin-grid): header arrows read the ONE declared sort spelling (objectui#8961) (#9604)

    ⭐⭐ errata 62b FIRED — 15th instance, and this one was visible in advance

    PR #9608's own base is f0f4d6c8e. M^ is 358aff8985 — another lane's PR (#9604), which landed while this one was queued. ⇒ a probe naming the base in advance would have compared f0f4d6c8e → 253c314184, the wrong pair, and would still have printed plausible numbers.

    ⭐ This one was predictable and was predicted: at enqueue time the queue read three-deep with #9608 stacked on #9604's queue ref, and this seat recorded then that M^ would not be the base. ⛔ The reading was still taken after the merge — a prediction is not a measurement, and the rule is what makes the prediction checkable rather than the other way round.

    ⚠️ Shallowness re-read this fire (true), deepened before any ^ resolution.

    Content probe — both-direction ablation, control from the probed file itself

    git diff --stat 358aff8985 253c314184 ⇒ 2 files, 149 insertions, 34 deletions — exactly the reviewed diff.

    reading in scripts/check-spec-symbol-derivation.mjs M M^
    PROBE CLAIM_PATTERNS_ALL (the module-private /g copies) 2 0
    PROBE matchAll(pattern) (the all-occurrences scan) 1 0
    FIRING CONTROL CLAIM_PATTERNS 4 2 ⇒ present at both refs and grew by exactly the private copy
    FIRING CONTROL CLAIM_WINDOW 8 8 ⇒ unchanged, so the grep reads the file at both refs

    ⇒ the zeros at M^ are readings, ⛔ not a dead predicate. And CLAIM_WINDOW 8→8 confirms on the merged tree what the review asserted: the window was not touched, so this tightened toward the declared subject rather than widening it.

    ⚠️ Half-state on close — repaired in the same action

    Fixes auto-closed the card completed and left pm:dispatched + assignee standing. Stripped via the four-step write, read back MATCHES: labels now priority:p2, tooling, domain:devx · assignees none. ⭐ That is now 5 of 5 landings this session leaving a half-state on close — ⛔ it is the repo's default behaviour, not an incident.

    What landed

    findClaim now tests every occurrence of each claim phrase per docblock instead of only the first, via matchAll over module-private /g copies — while the exported CLAIM_PATTERNS stays /g-free so no importer's exec/test can carry lastIndex state and make the gate non-deterministic. A pin holds that property.

    ⭐ The gate's declared subject — "a canonical-sounding comment with nothing behind it is a planted premise for the next session" — is now enforced against planted premises rather than against first occurrences. 0 docblocks newly fail, measured over a superset of the gate's corpus, so no repair work opened.

    ⚠️ Standing reading for whoever revisits this: triage pre-authorised that an empty escape measurement means p3, ⛔ not a reversal. The masking condition is live; no claim currently escapes it. ⛔ This seat did not re-grade — priority is triage's.


    Generated by Claude Code

  7. added a commit that references this issue on Sep 17, 2026
    253c314
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repopriority:p2tooling

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions