Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions docs/adr/0017-voucher-presence-authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ proposal in it, however complete that proposal's own evidence was.
on:

- **a `REMOTEID` column that was never read.** A window declares
`RemoteIdEvidence::Observed` or `NotRead`, because "no voucher carried one"
`ColumnEvidence::Observed` or `NotRead`, because "no voucher carried one"
and "the profile never fetched it" are different facts and only the first is
evidence. Where a proposal carries a `REMOTEID` and the window is `NotRead`,
that proposal's strongest key was never compared, so it **cannot be
Expand Down Expand Up @@ -216,6 +216,12 @@ work units, is refused as `ComparisonWorkTooLarge`. The second limit counts
posting-list walks and party-key checks, so it still applies when the pair count
is below one million but one party resolves to many candidate keys.

Ambiguous narration evidence is bounded separately: at most 64 raw marker
occurrences per voucher, 100,000 retained marker memberships, and 4 MiB of
marker-key bytes per window are admitted before the marker index is built. The
typed refusals preserve the same rule as the comparison bounds: no evidence is
silently truncated into an `Absent` or `Present` verdict.

### 3. The numbering method is declared, and its absence is an error

The decisive power of a voucher number depends entirely on the voucher type's
Expand Down Expand Up @@ -246,9 +252,9 @@ Per proposed voucher, exactly one of:
| `Absent` | No rule produced any candidate, in a window proven to cover it **and** proven to have been read whole | including this voucher in the import |

`PossiblyPresent` carries candidates labelled with the **rule that surfaced
each** — `SharedRemoteId`, `SharedVoucherNumber`,
`SameDatePartyAmount`, `SamePartyAmount`, `SameDateAmount`, `SameDateParty` —
ordered by rule and then by the book
each** — `SharedRemoteId`, `SharedNarrationMarker`,
`SharedVoucherNumber`, `SameDatePartyAmount`, `SamePartyAmount`, `SameDateAmount`,
`SameDateParty` — ordered by rule and then by the book
voucher's own ordering. **No candidate is marked best, likely or preferred, and
no score is emitted anywhere.**

Expand Down Expand Up @@ -297,7 +303,7 @@ leaves open:
rule that withholds `Absent` when a key was not compared applies with more
force to `Present`, because `Present` carries the higher bar and its error is
the silent one. So where a proposal supplies a `REMOTEID` and the window is
`RemoteIdEvidence::NotRead`, a unique number match returns
`ColumnEvidence::NotRead`, a unique number match returns
`RemoteIdEvidenceUnavailable` rather than `Present`: the number is decisive
on its own terms, but the evidence that could contradict it was skipped. A
proposal carrying no `REMOTEID` skipped nothing and still settles. An earlier
Expand Down Expand Up @@ -527,7 +533,7 @@ human-approved batch — this ADR does not move.
the desktop consumes the same function once a draft row carries a number and
a party.
- **`RemoteId` is contract-complete and not reachable from the shipped read**,
so the adapter declares `RemoteIdEvidence::NotRead` and the tool's schema
so the adapter declares `ColumnEvidence::NotRead` and the tool's schema
does not accept a `remote_id` at all. `render_agent_vouchers` does not
`FETCH REMOTEID`; only the AlterID change feed does. Accepting an input that
could only ever *withhold* a verdict would be worse than refusing it.
Expand Down
207 changes: 207 additions & 0 deletions docs/adr/0018-narration-marker-identity-basis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# 0018 — The narration marker as an identity basis for voucher presence

- Status: Accepted
- Date: 2026-09-11
- Amends: [0017 — Voucher presence authority](0017-voucher-presence-authority.md)
- Depends on: [0016 — Master binding authority](0016-master-binding-authority.md)

## Context

ADR 0017 gives presence two bases for `Present`: a Tally-assigned `REMOTEID`
read back on a previous visit, and a manual voucher number unique on both
sides. Both are real, and **neither reaches the case Bridge is uniquely placed
to answer**: *did I write this voucher myself?*

- `REMOTEID` does not reach it twice over. §3.3a verified Tally **overwrites**
the attribute, so the client key Bridge sent is not what comes back; and the
qualified `vouchers` read profile does not `FETCH REMOTEID` at all, which is
why every proposal carrying one currently returns
`RemoteIdEvidenceUnavailable` rather than an answer.
- A manual voucher number is the *operator's* series. It says nothing about who
posted the voucher, and under `Automatic` numbering it does not exist.

Meanwhile Bridge already writes an identity into a field Tally does not own.
Every import appends `[BRIDGE:<identity>]` to `NARRATION`; the qualified
`vouchers` profile already fetches `NARRATION`; and §9.8's batch-identity
observation records readback retaining that attribution across a round trip,
with the `REMOTEID` and the narration marker deriving from the same value.

So the channel exists, is already written, is already read, and has live
evidence behind it — and presence does not use it. That is the gap this ADR
closes.

### The hazard that shapes the whole contract

The marker is **not** the caller's transaction label. It is
`SHA-256("bridge.mcp.import.v1", batch_id, txn_id)` truncated to a UUID, where
`batch_id` is a random v4 UUID persisted per batch. That derivation exists
precisely because a caller's transaction label is, in the import module's own
words, "commonly reused" — §9.8 records a second batch reusing an earlier
caller label, and the domain-separated digest is what stopped the two colliding.

But the scheme field is `Option<ImportIdentityScheme>`, and `None` means an
older file whose marker is **the raw caller label**. Bridge never rewrites a
saved file, so books in the field can hold both kinds. A bare label like
`txn-001` is not unique across batches, and treating one as an identity would
match a proposal against an unrelated voucher from an unrelated import — a
false `Present`, which drops an invoice silently. That is the exact failure ADR
0017 exists to prevent, and it is reachable by *adding* this basis carelessly.

## Decision

### 1. The proposal's marker is derived, never accepted

A caller does not hand presence a marker string. It supplies the `batch_id` and
`bridge_txn_id` of the import it is asking about, and the adapter derives the
marker with the **same `import_identity` function the writer uses**. One
derivation, one place, no second implementation to drift.

This is not ceremony, it is the whole safety argument. A derived marker is a
digest over a random batch UUID, so it cannot equal a legacy caller label
except by deliberate contrivance. Accepting an arbitrary marker string would
let a caller pass `txn-001` and match a legacy voucher from an unrelated batch.
The input shape is what makes the hazard in the Context unreachable, rather
than a validation rule that has to be remembered.

### 2. Extraction is the adapter's job; the crate compares opaque strings

`[BRIDGE:<identity>]` is a **Bridge writer convention, not a Tally fact**, and
`bridge-tally-core` is the portable contract layer. So the adapter extracts the
marker from the observed narration and the crate receives an opaque string it
never parses.

The second reason is cost. An opaque string is hashable, so the book index is
built once and every proposal is a lookup. Extraction inside the crate would
mean a substring scan of every narration for every proposal — the shape of
allocation this contract has already had to correct twice.

### 3. One marker, and it must be identifying

A book voucher yields an identity marker only when its narration carries
**exactly one** well-formed `[BRIDGE:…]` occurrence *and* that occurrence is a
UUID of the version the writer stamps. Everything else — two markers, a
malformed one, or a well-formed legacy caller label — yields **no identity**.

Both halves fail closed, for different reasons. Two markers mean the voucher
claims two imports, which is the middle case ADR 0017 forbids resolving; the
import path already treats it as an error (`import_verification_tag_ambiguous`)
rather than taking the first. A non-UUID marker is a legacy-scheme write, and
§1 is exactly the reason it must not decide.

The **version** is checked and not only the spelling, because a transaction
label may legally *be* UUID-shaped: `valid_txn_id` admits hex and hyphens, so a
legacy-scheme write could carry a canonical v4 and a spelling check alone would
have called it batch-derived. `import_identity` builds through
`Uuid::Builder::from_custom_bytes`, which stamps version 8 and the RFC 4122
variant, so requiring those rejects the whole impersonable class rather than
the fraction of it that happens to look wrong.

Such a voucher is still a Bridge write, and losing that fact silently would be
its own defect. So the window reports `unidentified_bridge_writes`: a count of
rows carrying a reserved marker that could not identify one. Its well-formed
occurrences remain **non-decisive** `SharedNarrationMarker` candidates. An
occurrence on a different voucher conflicts with a manual-number, `REMOTEID`,
or identifying-marker selection rather than being discarded; an occurrence on
the selected voucher decides nothing by itself. Neither case can promote the
ambiguous narration to identity.

The core accepts at most 64 raw occurrences from one ambiguous narration before
cloning any value, then admits at most 100,000 retained marker memberships and
4 MiB of marker-key bytes across a window before building the marker index.
Excess is a typed refusal, never truncation, so the withheld evidence cannot be
silently lost to an allocation limit.

### 4. Whether the column was read is a fact about the read

`BookWindow` declares `ColumnEvidence::Observed` or `NotRead` for the narration
exactly as ADR 0017 §2 requires for `REMOTEID`, and for the same reason: "no
voucher carried a marker" and "the read never fetched narration" are different
facts and only the first is evidence. A proposal supplying an import identity
against a window that did not read narration is `MarkerEvidenceUnavailable` —
it cannot be `Absent`, and it cannot be `Present` on a weaker basis either,
because the evidence that could contradict that basis was skipped.

This ADR generalises `RemoteIdEvidence` into `ColumnEvidence` rather than
adding a second enum of identical shape. The concept was never about
`REMOTEID`; it was always "this column was or was not read".

### 5. The marker ranks with the other identities, and disagreement is reported

`NarrationMarker` is a third basis under ADR 0017 §5, not a tiebreaker and not
a fallback. Every rule there applies to it unchanged: unique on both sides,
one book voucher satisfies at most one proposal across bases, a cancelled or
optional voucher never yields `Present`, and a `Present` reports its
differences.

All identity lookups continue to be resolved **before any of them settles**, so
a marker selecting one voucher while a `REMOTEID` or a manual number selects
another is `IdentityConflict`. Ranking them would be the move ADR 0016 refuses
when an identifier contradicts an exact name, and adding a third signal makes
that more important rather than less: there are now three ways to disagree.

A unique marker also conflicts with a supplied manual number that selects no
observed voucher. That is `IdentityConflict`, with every matching identity kept
in the candidate set. An omitted number provides no contradictory identity;
an automatic number is not an identity. Both remain distinct from a supplied,
absent manual number.

### 6. What this basis does not reach, said plainly

The marker reaches **only vouchers Bridge itself wrote**. It says nothing about
the hand-keyed voucher, which is the case that produced ADR 0017's ₹36.13
finding and the case the capability was built for. This basis adds reach where
Bridge has written before; it does not reduce the other two bases' work, and it
must not be read as making the manual-number basis redundant.

### 7. The evidence, and its scope

§9.8's batch-identity and native-selector observations record the marker
surviving a round trip on a **Silver 7.1 Journal**. That is one product mode
and one voucher type. The marker's survival on other voucher types is
unqualified, and this ADR does not claim it.

What makes shipping that honest is the **direction of the unqualified
failure**. If a marker does not survive, the voucher reads as marker-absent and
the proposal falls through to the other bases — at worst producing a duplicate,
which is visible in the book and correctable. It cannot produce a false
`Present`, because a marker that was not written cannot match one that was.
The unqualified case fails toward the noisy direction, which is the posture ADR
0017 §7 sets out.

**Known limit.** An operator who edits a narration removes the marker, costing
a `Present` and yielding a duplicate. Same direction, same reasoning: visible
and correctable rather than silent.

## Consequences

- Presence answers "did Bridge write this?" for every voucher Bridge wrote
under the batch-derived scheme, with no change to a qualified read profile —
`NARRATION` is already fetched.
- The `voucher_presence` input grows `batch_id` and `bridge_txn_id` per
proposed voucher, both optional. A proposal that supplies neither behaves
exactly as it does under ADR 0017.
- `RemoteIdEvidence` is renamed to `ColumnEvidence`. One type, two columns.
- Books written by the legacy scheme get a count, not an identity. If those
turn out to be common in the field, the follow-up is a qualification of
legacy-label matching under a batch the caller names — deliberately not
attempted here, because it needs the caller to bound the batch and this ADR
does not have that input.

## Alternatives rejected

**Accept a marker string from the caller.** Simpler input, and it makes the
legacy-collision hazard a validation rule someone has to remember instead of a
shape that cannot express the bad case. Rejected on §1.

**Match legacy caller labels too, for reach.** This is the one alternative that
would increase coverage on older books, and it produces exactly the silent
failure — a label matching across unrelated batches — that presence exists to
prevent. Reach is not worth a false `Present`.

**Extract the marker inside the crate.** Puts a Bridge writer convention in the
portable layer and turns a hash lookup into a per-proposal substring scan.

**Treat the first of several markers as the identity.** Cheap, matches what one
existing readback helper does, and silently resolves the middle case. The
import path's stricter helper refuses it, and this contract agrees with the
stricter one.
6 changes: 3 additions & 3 deletions docs/module-decomposition.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ manifest cannot tell you which, because it records paths and not reasons — and
that is the actual problem: the seal's boundary is currently an accident of
history rather than a decision anyone can review.

That needs capacity in `MAX_SURFACE_FILES`, and there is none by design — 217 of
217.
That needs capacity in `MAX_SURFACE_FILES`, and there is none by design — 218 of
218.

**Do not read that as a shortage to be fixed.** `RESERVED_SURFACE_FILES` is
documented as capacity for *"one small cohesive surface change"*, and the cap's
own rationale says it *"makes further unreviewed additions an explicit
compatibility-surface decision"*, closing with *"one file for one named reason —
not headroom."* The cap has been raised four times, each reason recorded in the
not headroom."* The cap has been raised five times, each reason recorded in the
comment, and three of those raises came from branches that could not see each
other. The friction is the control.

Expand Down
2 changes: 1 addition & 1 deletion docs/proposed-rust-module-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ argues for doing it where it helps and against treating it as risky.
## Where the general advice does not fit this repository

This codebase has a constraint most advice does not consider: **files are
pinned**. `docs/tally/compatibility/compatibility-surface.json` binds 217 paths
pinned**. `docs/tally/compatibility/compatibility-surface.json` binds 218 paths
by SHA-256, and the evidence receipts beneath it attest behaviour *of those
bytes*. Splitting a pinned file is not a neutral move:

Expand Down
2 changes: 1 addition & 1 deletion docs/tally/compatibility/compatibility-matrix.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e",
"compatibility_surface_sha256": "f26f33a831ad741e2918ae32c5db9af16cfec713f102e30df6a30b9f886ac6cd",
"compatibility_surface_sha256": "632ae96b7599ff062311687f9188c8001ec53b655c07cf7986be878f419e7781",
"claims": [
{
"claim_id": "erp9-6-6-3-windows-education-xml-one-company",
Expand Down
18 changes: 11 additions & 7 deletions docs/tally/compatibility/compatibility-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
},
{
"path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs",
"sha256": "652f57e441b05f4bdb25c8e53e9bcd9000641491e2fb8479c0a6344764b2b54c"
"sha256": "46061242d2502a140905c7acbb235d56e3912dd9362930e64c32cfa37083ef53"
},
{
"path": "src-tauri/crates/bridge-tally-core/src/lib.rs",
Expand Down Expand Up @@ -335,27 +335,31 @@
},
{
"path": "src-tauri/src/agent_catalog.rs",
"sha256": "e36e4cbe60e6355226b8779e82395ec79309213f8c0dca59aea8bbc745252601"
"sha256": "55632c680704c2784545e422cf6847b81d2423191d651a0119d45188d9bf2671"
},
{
"path": "src-tauri/src/agent_desktop_journal.rs",
"sha256": "9922e27217b13f1834c2cd40e5f99a9ada32eae1be180dd46dd4ba209bb41ae6"
},
{
"path": "src-tauri/src/agent_import.rs",
"sha256": "47c1dc77fa33d7e4bcece1cb289e4a3535881391fe45f392e2dca0daa937930f"
"sha256": "0259e8828d9c6bea10dd98f523e7c982137eed5354097efd5b866475d703f74e"
},
{
"path": "src-tauri/src/agent_import_identity.rs",
"sha256": "124fbf3d2d255523ce9fc9dbf32502eb3c8dd013c9dd2a7b206eafd966611d89"
Comment thread
lamemustafa marked this conversation as resolved.
},
{
"path": "src-tauri/src/agent_ledgers.rs",
"sha256": "a891e4ac51addc9dc17ae28ce4634bd2cfeb8f3f0df78e072290771d29a3131b"
},
{
"path": "src-tauri/src/agent_presence.rs",
"sha256": "44fa9f2482f1c1a76249bccdfa0b5e2626d816e9a405b721e04c8ef7da9d8ec5"
"sha256": "0bfeb05248b0d371e6a87b4b004c9c3815b5590017c6bd3231277db12b030c8d"
},
{
"path": "src-tauri/src/agent_presence_tests.rs",
"sha256": "f2479d47fa01119388cb7e007b5b554fa47a57292251f4489224bf130bfd272c"
"sha256": "11d35d8a5dd60178f02d070c8b33f9b2c1cf44cb7cf5517eac11430f2aa74796"
},
{
"path": "src-tauri/src/agent_read_profiles.rs",
Expand Down Expand Up @@ -835,7 +839,7 @@
},
{
"path": "tools/bridge-tally-compatibility/src/lib.rs",
"sha256": "e61a4815ce3d887fdb46481917546002f7d90d0ec913752aebaa68d232aa79e1"
"sha256": "0ea7fb49d64fddab95f148048c92c07d9f9cd162a973912e3a56df2051ee3b99"
},
{
"path": "tools/bridge-tally-compatibility/src/main.rs",
Expand Down Expand Up @@ -870,5 +874,5 @@
"sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a"
}
],
"manifest_sha256": "f26f33a831ad741e2918ae32c5db9af16cfec713f102e30df6a30b9f886ac6cd"
"manifest_sha256": "632ae96b7599ff062311687f9188c8001ec53b655c07cf7986be878f419e7781"
}
Loading