Skip to content

docs(tally): four findings left open when #306 was merged too early - #314

Merged
lamemustafa merged 52 commits into
masterfrom
fix/306-followups
Sep 15, 2026
Merged

lamemustafa merged 52 commits into
masterfrom
fix/306-followups

Conversation

@lamemustafa

@lamemustafa lamemustafa commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Functional summary

The master-creation and recovery guidance could authorize a write using an incomplete catalogue, omit a conservative collision case, or resend an unknown outcome. This change makes those prerequisites explicit, limits identity normalization rules to deciding paths, and keeps measured observations separate from implementation policy. It rectifies review findings left after #306; related debt is tracked in #317.

  • Creation requires qualified source completeness plus a qualified mutation-time condition or proven exclusive-write window. A partial read or another ordinary pre-read cannot provide that authority. The plan repeats the same mutation-time prerequisites. The accepted quiet-company Journal preview is not promoted to master-create evidence.
  • The conservative refusal detector covers slash, hyphen and space in both directions and composition. Broad candidate/refusal comparisons remain permitted; they cannot resolve identity or authorize a write.
  • Unknown-outcome recovery retains the original batch and remains read-only. The unqualified redispatch and three-write-retry prescriptions are removed from both the active flow and its tests.
  • The protocol reference now contains observations only in §9.5 and points to the guide/playbook for identity policy.
  • Phase 2 uses only measured directional comparisons in the licensed 7.1 Silver ledger scope. Slash-bearing cross-spellings are withheld from automatic binding; the recorded slash-candidate/space-master experiment does not qualify its reverse or a symmetric canonical form. Binding an existing exact-name master also requires relevant approved fields to match; absent evidence or a difference leaves a visible conflict and blocks dependent dispatch.

Reuse/deletion: updates the existing reference, guide and playbook; removes duplicated policy and unsupported recovery branches. No new runtime component or dependency is added. Without the correction, an implementer can still follow stronger unsafe instructions elsewhere in the same documentation.

Test or reproduction command

Validation at af4727e29863d30e96d6ec98dd0b02bfa217dc50:

  • node scripts/check-protocol-section-numbers.mjs — 81 unique section numbers, permitted historical 1.2 duplicate only.
  • node --test scripts/check-protocol-section-numbers.test.mjs — all section-gate contracts pass.
  • cargo run --locked -p bridge-tally-compatibility -- gate ...compatibility_gate_passed:unknown_claims=11:evidenced_claims=0.
  • check-matrix-markdown and git diff --check pass. The final reference edit was rehashed, sealed, and repointed using the compatibility tool; the gate passes.
  • Current master 9da3b720 is integrated. Compatibility membership is unchanged at 212 paths, including the landed ledger pin; generated metadata was regenerated using the tooling.
  • Independent review found the remaining active redispatch prescription; the final commits correct it across the state transition, flow, global invariants, and test expectations. A second independent review accepted the prior candidate; subsequent bot findings exposed remaining stale recovery and scope guidance. This final revision aligns the plan, protocol and playbook, restricts the measured fold to Silver, and corrects the identity pointer to guide §3.5. The final direction review also corrected §9.4d’s inference without changing its recorded experiments. Root independently reviewed the full correction and the regenerated 212-pin surface; no merge was performed while findings were open.

Migration/rollback: documentation only; no persisted schema or sync behavior changes. Revert the documentation commit to roll back wording, retaining the known qualification gaps for any implementation.

Security impact: narrows write/replay authority. No live Tally request, customer data, credentials, new protocol capture, or new product qualification is introduced. Native platform execution is not claimed by documentation checks.

Exact storage keys now remain separate from qualified lookup folds, with ambiguity preserved; CREATED/ALTERED counters detect a race without being promoted to prevention. The reconciled head preserves both committed histories through normal merges. The final reconciliation also marks the NFC/NFD product/release classification unverified because the cited provenance retains only EDU and the date, scopes the numbering inference to the measured failed Alter, and explicitly states that a create has no pre-image of an unobserved foreign master. All 212 pins were checked against file bytes after current-master integration.

Final integration removes the accidentally tracked fixture-test scratch directory. Protocol §9.4 now records measured gateway behavior and links to Implementation Guide §3.6 for policy. A dated plan §8.19 deviation withdraws the remaining duplicate-redispatch criterion: unknown outcomes retain the original batch and require read-only resolution. Fixture-byte integrity and its Node contract suite pass, as do the protocol numbering contracts and the regenerated compatibility gate.

Merge hold: #329 tracks the shipped ledger binder that still promotes unqualified folded-name matches. The corresponding P1 review remains open until that runtime correction lands; this documentation PR does not claim to repair it.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-12T18:42:46.784017Z af4727e New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ae3e62bb3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/IMPLEMENTATION_GUIDE.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 10f0b3e4aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/IMPROVEMENT_PLAN_2026H2.md
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
… dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40dee28233

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45e274564b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/IMPROVEMENT_PLAN_2026H2.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
… dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41e2168873

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/IMPROVEMENT_PLAN_2026H2.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f4370fdae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/IMPLEMENTATION_GUIDE.md
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e976f69ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Three sessions hit the same defect independently in one day: a pinned file's
bytes moved and the compatibility surface was not resealed. Once by editing,
once by running `cargo fmt` *after* resealing, and once by rebasing — where the
rebase takes the base's manifest and the author touches nothing.

That spread of causes is the point. The rule people had written down was "the
reseal is the last step before `git add`", and each of us broke it while
believing we were following it, because each filed it under the *situation* we
had just been in rather than under the actual invariant: **any operation that
can change the bytes of a pinned file — an edit, a formatter, a merge, a
rebase — invalidates the seal, and the reseal runs after the last of them.**

Nothing in the local loop re-reads pins before a commit, so CI's gate is the
only thing that notices, and every instance therefore reaches a reviewer
instead of its author. That makes it a class, not a set of mistakes, and a
class is worth closing here rather than writing down again.

The check needs no checkout: read the 211 pinned paths from the manifest at the
PR head, intersect with the PR's changed files, and require the manifest to
have moved if any of them did.

It is deliberately weaker than CI's gate and says so: it proves the reseal was
*performed*, not that the hashes are *right*. Only the real gate proves that.
But every instance observed was a reseal that never ran at all, so this catches
the whole observed failure while costing one API call.

Verified in three directions rather than two: a pinned file changed without the
manifest blocks; the same change with the manifest passes; a PR touching the
manifest alone has nothing to reseal and passes. Against live PRs, #314 reports
one pinned file with the manifest moved alongside it, and #320 reports nothing
pinned.

Credit where due — this was suggested by the lane on #288, which had just been
bitten by the `cargo fmt` variant, on the grounds that closing the class beats
closing the instances. It was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70c89efb3c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/TALLY_PROTOCOL_REFERENCE.md Outdated
t and others added 9 commits September 12, 2026 14:01
These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ne for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Three sessions hit the same defect independently in one day: a pinned file's
bytes moved and the compatibility surface was not resealed. Once by editing,
once by running `cargo fmt` *after* resealing, and once by rebasing — where the
rebase takes the base's manifest and the author touches nothing.

That spread of causes is the point. The rule people had written down was "the
reseal is the last step before `git add`", and each of us broke it while
believing we were following it, because each filed it under the *situation* we
had just been in rather than under the actual invariant: **any operation that
can change the bytes of a pinned file — an edit, a formatter, a merge, a
rebase — invalidates the seal, and the reseal runs after the last of them.**

Nothing in the local loop re-reads pins before a commit, so CI's gate is the
only thing that notices, and every instance therefore reaches a reviewer
instead of its author. That makes it a class, not a set of mistakes, and a
class is worth closing here rather than writing down again.

The check needs no checkout: read the 211 pinned paths from the manifest at the
PR head, intersect with the PR's changed files, and require the manifest to
have moved if any of them did.

It is deliberately weaker than CI's gate and says so: it proves the reseal was
*performed*, not that the hashes are *right*. Only the real gate proves that.
But every instance observed was a reseal that never ran at all, so this catches
the whole observed failure while costing one API call.

Verified in three directions rather than two: a pinned file changed without the
manifest blocks; the same change with the manifest passes; a PR touching the
manifest alone has nothing to reseal and passes. Against live PRs, #314 reports
one pinned file with the manifest moved alongside it, and #320 reports nothing
pinned.

Credit where due — this was suggested by the lane on #288, which had just been
bitten by the `cargo fmt` variant, on the grounds that closing the class beats
closing the instances. It was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 29a00c0c64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md
Comment thread docs/tally/TALLY_PROTOCOL_REFERENCE.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
t and others added 6 commits September 12, 2026 19:12
…ne for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…asured

Two findings, opposite in direction, and together they are the binder/detector
distinction working as written — I had applied §9.4d's slash row to neither.

**The detector omitted slash.** §9.4d records a **slash supplied against a
space-bearing master** as MATCHED on licensed 7.1, so a requested `A/B` reaches
an existing `A B`. A detector folding only hyphen and space lets that through
to a `Create` that overwrites the master. Slash is in the detector now, both
ways, and the paragraph says why it is easy to omit: §9.4b never sent one, so
the row exists only in §9.4d and a reader working from the older table will not
find it.

**The binder must NOT fold slash symmetrically.** §9.4d's rows are directional
as written — the left column is what the integration supplies, the right is
what the master carries. `a slash where the master has a space` is MATCHED; the
reverse, supplying a space against a master carrying `/`, has **no row** and is
UNVERIFIED. Hyphen is the exception that proves the shape: §9.4d carries *both*
directions for it as two separate rows, which is why both may be bound.

So the rule now stated in the gate: **the reverse of a measured row is not a
measured row.** Reading that table as a set of equivalences rather than a set of
directed observations is how an unmeasured direction gets bound — and it is the
same mistake §9.4b's own text warns about for the hyphen case, one table later.

The two findings sit either side of a line this PR already drew: the detector
folds wide because it concludes in a refusal, the binder folds only what was
measured because it concludes in a write. Getting slash wrong in both directions
at once is what made that concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e is detected, not prevented

Three findings, all P1, and two of them are the same mistake at two phases.

**Folding a name to build a KEY destroys a row.** A catalogue holding both
`Alpha-Beta` and `Alpha Beta` holds two masters Tally keeps apart. Routing the
key through a fold collapses them into one mirror row before any ambiguity
handling can run — the second row is not ambiguous, it is *gone*, and nothing
downstream can recover a distinction destroyed on the way in. The same happens
to a case-differing pair under the directional predicate.

Both Phase 2 and Phase 4 now say it: **store rows under their exact codepoints;
fold only when resolving a request against those rows.** A fold reaching more
than one row is then an ambiguity to surface, which is only possible because
both rows still exist. This is the read-side twin of the binder/detector rule
already in this document — where a fold sits decides what it can destroy.

**The pre-dispatch guard cannot close its own window, and saying so is the
fix.** Another Tally client or an operator can create a colliding master between
the pre-read and the dispatch; §3.6's overwrite then happens anyway. Bridge's
single-writer actor owns Bridge's writes, not Tally's, and no amount of reading
earlier removes that.

What closes it is that the gateway reports the collision where it cannot hide:
a `Create` that finds a match returns **`CREATED=0, ALTERED=1`**. A create
dispatched from step 3a must therefore assert `CREATED=1`, and `ALTERED=1` is
not a success with a different label — it means a master that existed at
dispatch time has been overwritten with this payload. Alarm, halt, restore from
the pre-image; never a promotion to CONFIRMED.

Stated as detection rather than prevention on purpose. The guard narrows the
window to the dispatch itself; the counter assertion is what makes losing that
race loud instead of silent. A guard that claimed to eliminate the race would be
the more dangerous document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…odex/rectify-pr314-20260912

# Conflicts:
#	docs/tally/PROMPT_PLAYBOOK.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e6541f323d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/IMPLEMENTATION_GUIDE.md
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
t and others added 3 commits September 12, 2026 19:25
**The create race had a remedy Bridge cannot perform.** Step 3a said an
unexpected `ALTERED=1` meant "alarm, halt, and restore from the pre-image". The
outbox carries a pre-image for **alters** — step 1 says so, `(alters)` — because
an alter knows what it is replacing. A create does not: the pre-read found
nothing, so there is no pre-image, and the master a foreign writer created in
the window is one Bridge never observed. Its group, opening balance and
registration are gone and are not in our records.

Prescribing that recovery would have sent an operator looking for a pre-image
that was never taken, during an incident. What the step now gives them is the
truth and the material to act on it: the master name, the exact payload that
overwrote it, the dispatch time, and a statement that the prior content was
never observed by Bridge and must be reconstructed from the client's own records
or a Tally backup.

It also changes what the window is worth. An unrecoverable failure is not made
acceptable by being detected — detection stops it compounding, nothing more — so
the re-read must happen **immediately before dispatch, not once per batch**.
Every instruction between the read and the write is window.

**Two scope corrections in the protocol reference.**

§9.8's first consequence read "voucher-number-based idempotency only works with
Manual numbering" — a claim about idempotency in general, from a table whose
only behavioural column is *failed Alter*. It now says what was measured before
drawing the consequence, and points at the section's own rule forbidding the
carry to a different request identity mechanism.

And the NFC/NFD capture is **standard TallyPrime 7.1 in Educational mode**, not
§0's **Edit Log 7.0** Educational baseline. I corrected "licensed" to "EDU" two
commits ago and flattened two different products into one word doing it. They
are different SKUs that happen to share a licence mode, and the distinction
decides which other rows this one may be read beside. Both the reference and the
playbook's scope gate now say which it is and which two things it therefore
qualifies: neither.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ex/rectify-pr314-20260912

# Conflicts:
#	docs/tally/IMPROVEMENT_PLAN_2026H2.md
#	docs/tally/PROMPT_PLAYBOOK.md
#	docs/tally/TALLY_PROTOCOL_REFERENCE.md
#	docs/tally/compatibility/compatibility-matrix.json
#	docs/tally/compatibility/compatibility-surface.json

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed632dcd19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/PROMPT_PLAYBOOK.md Outdated
Comment thread docs/tally/IMPLEMENTATION_GUIDE.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea3819b211

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md
§9.4 still ended "Pre-read before creating" — which reads as sufficient and is
not. A foreign writer can create the master between the read and the dispatch,
and this section's own overwrite then lands on a master Bridge never observed:
no pre-image, prior content unrecoverable.

The behavioural authority was the last site still saying it. The guide and the
playbook had both been corrected; an implementer reading the protocol reference,
which is the document they trust over either, would have followed the stale rule.

Two things this states that the earlier correction did not:

**Narrowing the gap does not close it, and neither does reading again.** My own
earlier wording — "re-read immediately before dispatch, not once per batch" —
invited an implementer to treat a tighter read as the mitigation. It is not.
Only a qualified mutation-time condition or a proven exclusive-write window
covers that interval; another ordinary pre-read is the same check, sooner.

**So the assertion moves to the response.** A create MUST assert `CREATED=1`;
`ALTERED=1` is an overwrite alarm and a manual halt, never a success under a
different counter. That is checkable at the only moment that is not a window.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e8096182c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/tally/PROMPT_PLAYBOOK.md
Comment thread .fixture-integrity-tIwlPf/fixtures/synthetic.xml Outdated
Comment thread docs/tally/TALLY_PROTOCOL_REFERENCE.md Outdated
master narrowed the same three paragraphs of TALLY_PROTOCOL_REFERENCE.md
via #331 that this branch narrowed independently. Both say the measured
slash case is directional and licenses no canonical form; the wordings
differ, so git could not merge them.

Resolution takes master's text for all three, because that wording shipped
with the code change that removed BindingBasis::NormalizedName and because
it withdraws the earlier claim as binding authority rather than erasing it
from the probe history. This branch's operational RULE blockquote is kept
after master's scope paragraph: master states the limit abstractly (does not
generalize across product, tier, object class, direction or caller) while
the blockquote names the concrete failure a reader must avoid (a fold
treating punctuation as a class merges masters the gateway keeps apart).

The branch's other three documents do not overlap with master and are
carried unchanged.

Compatibility surface resealed: rehash reported one changed entry, the
protocol reference, and a confirming pass reports zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@lamemustafa
lamemustafa merged commit 556b045 into master Sep 15, 2026
11 checks passed
@lamemustafa
lamemustafa deleted the fix/306-followups branch September 15, 2026 09:17
lamemustafa pushed a commit that referenced this pull request Sep 15, 2026
The code conflicts this branch was expected to carry (agent_catalog.rs,
agent_import.rs, agent_import_tests.rs) auto-merged once #314 and #331 were
on master; only documentation and the compatibility manifests conflicted.

This branch carried a copy of #314's documentation corrections while #314 was
under review, so both sides had independently reworded the same paragraphs.
Resolution took whichever text states the post-#331 reality explicitly:

- TALLY_PROTOCOL_REFERENCE.md keeps master's wording. Its "Superseded
  interpretation" block withdraws the earlier claim as binding authority
  rather than erasing it, and already states the candidate-only rule.
- PROMPT_PLAYBOOK.md, IMPLEMENTATION_GUIDE.md and IMPROVEMENT_PLAN_2026H2.md
  keep this branch's wording, which names the binder directly: folded names
  remain candidates for operator selection and exact revalidation. Master's
  IMPLEMENTATION_GUIDE line implied a recorded directional comparison could
  still authorize automatic binding, which is no longer true after #331
  removed BindingBasis::NormalizedName.

Checked that taking this branch's PROMPT_PLAYBOOK did not drop #314's merged
content: the staged file differs from master by 12 added and 5 removed lines,
and those 5 are exactly the two reworded hunks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa added a commit that referenced this pull request Sep 15, 2026
* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep unknown-outcome recovery read-only across active guidance

* docs(tally): keep unknown outcomes out of dispatchable state

* fix(scripts): bind short masks to captured IMPS field context

* docs(tally): require intent fields before binding existing masters

* fix(scripts): withhold mask role when capture context is omitted

* fix(scripts): retain mask context independently of table furniture

* docs(tally): align scoped qualification and held recovery across plans

* docs: keep slash matching directional

* docs: limit Silver slash comparison direction

* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a measured row is directional, and its reverse is not measured

Two findings, opposite in direction, and together they are the binder/detector
distinction working as written — I had applied §9.4d's slash row to neither.

**The detector omitted slash.** §9.4d records a **slash supplied against a
space-bearing master** as MATCHED on licensed 7.1, so a requested `A/B` reaches
an existing `A B`. A detector folding only hyphen and space lets that through
to a `Create` that overwrites the master. Slash is in the detector now, both
ways, and the paragraph says why it is easy to omit: §9.4b never sent one, so
the row exists only in §9.4d and a reader working from the older table will not
find it.

**The binder must NOT fold slash symmetrically.** §9.4d's rows are directional
as written — the left column is what the integration supplies, the right is
what the master carries. `a slash where the master has a space` is MATCHED; the
reverse, supplying a space against a master carrying `/`, has **no row** and is
UNVERIFIED. Hyphen is the exception that proves the shape: §9.4d carries *both*
directions for it as two separate rows, which is why both may be bound.

So the rule now stated in the gate: **the reverse of a measured row is not a
measured row.** Reading that table as a set of equivalences rather than a set of
directed observations is how an unmeasured direction gets bound — and it is the
same mistake §9.4b's own text warns about for the hyphen case, one table later.

The two findings sit either side of a line this PR already drew: the detector
folds wide because it concludes in a refusal, the binder folds only what was
measured because it concludes in a write. Getting slash wrong in both directions
at once is what made that concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tally): reseal the surface after rebasing onto #310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): the short mask is an IMPS convention, not a global one

Scoping the mask decision per token was right; scoping it to the whole file was
not. `bank_statement_import` recognises `[Xx]+\d+` **only** inside an `IMPS/`
component, behind an alphabetic prefix and hyphens. `_is_mask` applied it
everywhere, so `X99` in any field at all was classified as masking and
`_fake_token` returned `X11` — carrying a customer's `X` into a public fixture.

That is the defect this function was written to fix, reintroduced one revision
later by widening the rule past the parser it mirrors. The first version decided
per character and leaked; the second decided per token but ignored context and
leaked again, in a narrower place.

The asymmetry worth stating, because it decides every future version of this
question: **a sanitiser may be narrower than the parser — the cost is a
fabricated mask shape — but never wider, because the cost there is a customer
character preserved verbatim.** Given a doubt about scope, take the narrower
reading.

`_scrub_plain` already holds the surrounding field, so the context the token
cannot carry is available one frame up and is passed down. The memo is keyed on
`(token, in_imps)` so the same token cannot be cached under the wrong scope.

Mutation-checked in **both** directions, which the previous version's tests
could not do: making the short form global again fails the three
outside-an-IMPS-field cases, and removing it entirely fails the three
inside-an-IMPS-field cases. A rule with a scope needs a test on each side of
the boundary, or it only ever proves one half of itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a fold belongs at lookup, never in the key — and the race is detected, not prevented

Three findings, all P1, and two of them are the same mistake at two phases.

**Folding a name to build a KEY destroys a row.** A catalogue holding both
`Alpha-Beta` and `Alpha Beta` holds two masters Tally keeps apart. Routing the
key through a fold collapses them into one mirror row before any ambiguity
handling can run — the second row is not ambiguous, it is *gone*, and nothing
downstream can recover a distinction destroyed on the way in. The same happens
to a case-differing pair under the directional predicate.

Both Phase 2 and Phase 4 now say it: **store rows under their exact codepoints;
fold only when resolving a request against those rows.** A fold reaching more
than one row is then an ambiguity to surface, which is only possible because
both rows still exist. This is the read-side twin of the binder/detector rule
already in this document — where a fold sits decides what it can destroy.

**The pre-dispatch guard cannot close its own window, and saying so is the
fix.** Another Tally client or an operator can create a colliding master between
the pre-read and the dispatch; §3.6's overwrite then happens anyway. Bridge's
single-writer actor owns Bridge's writes, not Tally's, and no amount of reading
earlier removes that.

What closes it is that the gateway reports the collision where it cannot hide:
a `Create` that finds a match returns **`CREATED=0, ALTERED=1`**. A create
dispatched from step 3a must therefore assert `CREATED=1`, and `ALTERED=1` is
not a success with a different label — it means a master that existed at
dispatch time has been overwritten with this payload. Alarm, halt, restore from
the pre-image; never a promotion to CONFIRMED.

Stated as detection rather than prevention on purpose. The guard narrows the
window to the dispatch itself; the counter assertion is what makes losing that
race loud instead of silent. A guard that claimed to eliminate the race would be
the more dangerous document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which …
lamemustafa pushed a commit that referenced this pull request Sep 15, 2026
Conflicts were the two compatibility manifests, TALLY_PROTOCOL_REFERENCE.md,
and the bank-statement sanitiser plus its test, which #316 also touched.

- sanitise-bbox-capture.py takes this branch's side: it is a strict superset,
  carrying the same encoding="utf-8" read that master added plus this
  branch's keep-list materialisation and _load_parser call.
- sanitise-bbox-capture.test.py takes this branch's side. _load_parser does
  not exist on master, so master's block is the pre-#332 shape; this branch's
  version adds the EvidenceRefusal handling that matches its own code, plus
  boundary controls for cropped rows, wrong-profile parsing and row-count
  mismatch.
- TALLY_PROTOCOL_REFERENCE.md takes master's wording, consistent with how the
  same three paragraphs were resolved when #314 and #316 landed.

Verified rather than assumed: scripts/sanitise-bbox-capture.test.py runs
green on the merged tree ("all sanitiser contracts hold").

Compatibility surface resealed; a confirming rehash reports zero changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa added a commit that referenced this pull request Sep 15, 2026
* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep unknown-outcome recovery read-only across active guidance

* docs(tally): keep unknown outcomes out of dispatchable state

* fix(scripts): bind short masks to captured IMPS field context

* docs(tally): require intent fields before binding existing masters

* fix(scripts): withhold mask role when capture context is omitted

* fix(scripts): retain mask context independently of table furniture

* docs(tally): align scoped qualification and held recovery across plans

* docs: keep slash matching directional

* docs: limit Silver slash comparison direction

* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a measured row is directional, and its reverse is not measured

Two findings, opposite in direction, and together they are the binder/detector
distinction working as written — I had applied §9.4d's slash row to neither.

**The detector omitted slash.** §9.4d records a **slash supplied against a
space-bearing master** as MATCHED on licensed 7.1, so a requested `A/B` reaches
an existing `A B`. A detector folding only hyphen and space lets that through
to a `Create` that overwrites the master. Slash is in the detector now, both
ways, and the paragraph says why it is easy to omit: §9.4b never sent one, so
the row exists only in §9.4d and a reader working from the older table will not
find it.

**The binder must NOT fold slash symmetrically.** §9.4d's rows are directional
as written — the left column is what the integration supplies, the right is
what the master carries. `a slash where the master has a space` is MATCHED; the
reverse, supplying a space against a master carrying `/`, has **no row** and is
UNVERIFIED. Hyphen is the exception that proves the shape: §9.4d carries *both*
directions for it as two separate rows, which is why both may be bound.

So the rule now stated in the gate: **the reverse of a measured row is not a
measured row.** Reading that table as a set of equivalences rather than a set of
directed observations is how an unmeasured direction gets bound — and it is the
same mistake §9.4b's own text warns about for the hyphen case, one table later.

The two findings sit either side of a line this PR already drew: the detector
folds wide because it concludes in a refusal, the binder folds only what was
measured because it concludes in a write. Getting slash wrong in both directions
at once is what made that concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tally): reseal the surface after rebasing onto #310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): the short mask is an IMPS convention, not a global one

Scoping the mask decision per token was right; scoping it to the whole file was
not. `bank_statement_import` recognises `[Xx]+\d+` **only** inside an `IMPS/`
component, behind an alphabetic prefix and hyphens. `_is_mask` applied it
everywhere, so `X99` in any field at all was classified as masking and
`_fake_token` returned `X11` — carrying a customer's `X` into a public fixture.

That is the defect this function was written to fix, reintroduced one revision
later by widening the rule past the parser it mirrors. The first version decided
per character and leaked; the second decided per token but ignored context and
leaked again, in a narrower place.

The asymmetry worth stating, because it decides every future version of this
question: **a sanitiser may be narrower than the parser — the cost is a
fabricated mask shape — but never wider, because the cost there is a customer
character preserved verbatim.** Given a doubt about scope, take the narrower
reading.

`_scrub_plain` already holds the surrounding field, so the context the token
cannot carry is available one frame up and is passed down. The memo is keyed on
`(token, in_imps)` so the same token cannot be cached under the wrong scope.

Mutation-checked in **both** directions, which the previous version's tests
could not do: making the short form global again fails the three
outside-an-IMPS-field cases, and removing it entirely fails the three
inside-an-IMPS-field cases. A rule with a scope needs a test on each side of
the boundary, or it only ever proves one half of itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a fold belongs at lookup, never in the key — and the race is detected, not prevented

Three findings, all P1, and two of them are the same mistake at two phases.

**Folding a name to build a KEY destroys a row.** A catalogue holding both
`Alpha-Beta` and `Alpha Beta` holds two masters Tally keeps apart. Routing the
key through a fold collapses them into one mirror row before any ambiguity
handling can run — the second row is not ambiguous, it is *gone*, and nothing
downstream can recover a distinction destroyed on the way in. The same happens
to a case-differing pair under the directional predicate.

Both Phase 2 and Phase 4 now say it: **store rows under their exact codepoints;
fold only when resolving a request against those rows.** A fold reaching more
than one row is then an ambiguity to surface, which is only possible because
both rows still exist. This is the read-side twin of the binder/detector rule
already in this document — where a fold sits decides what it can destroy.

**The pre-dispatch guard cannot close its own window, and saying so is the
fix.** Another Tally client or an operator can create a colliding master between
the pre-read and the dispatch; §3.6's overwrite then happens anyway. Bridge's
single-writer actor owns Bridge's writes, not Tally's, and no amount of reading
earlier removes that.

What closes it is that the gateway reports the collision where it cannot hide:
a `Create` that finds a match returns **`CREATED=0, ALTERED=1`**. A create
dispatched from step 3a must therefore assert `CREATED=1`, and `ALTERED=1` is
not a success with a different label — it means a master that existed at
dispatch time has been overwritten with this payload. Alarm, halt, restore from
the pre-image; never a promotion to CONFIRMED.

Stated as detection rather than prevention on purpose. The guard narrows the
window to the dispatch itself; the counter assertion is what makes losing that
race loud instead of silent. A guard that claimed to eliminate the race would be
the more dangerous document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failu…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant