Skip to content

ledger: give every strong id a PayloadShapeTag, so a swapped-id payload stops fingerprinting identically - #320

Merged
Yaraslaut merged 1 commit into
masterfrom
feat/ledger-payload-shape-tags
Aug 27, 2026
Merged

ledger: give every strong id a PayloadShapeTag, so a swapped-id payload stops fingerprinting identically#320
Yaraslaut merged 1 commit into
masterfrom
feat/ledger-payload-shape-tags

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

The gap, reproduced before fixing

include/morph/core/payload_shape_tag.hpp:10-16 states the problem the seam exists for: a type carrying its own glz::meta has no reflected members to decompose, so it renders as the bare tag x and is indistinguishable from every other such type.

grep -rn PayloadShapeTag examples/ returned nothing — no rung had ever declared one. All eight of ledger's ids rendered as x:

LedgerId -> x   AccountId -> x   JournalId -> x   CategoryId  -> x
BudgetId -> x   RuleId    -> x   ReportJobId -> x  ImportOpId -> x

So two structurally different actions fingerprinted identically:

"(journalId:x,ledgerId:x)"  !=  "(journalId:x,ledgerId:x)"      // UndoTransaction vs. ids swapped
"2:bb198e6aad4d30a8"        !=  "2:bb198e6aad4d30a8"

SetCategory{AccountId, CategoryId, RuleId, …} behaves the same way. LADDER.md:139-142 names this as the payload-evolution risk that "bites rungs 5 and 7 too".

The replay result is the part worth reading twice. replay() did not refuse the re-stamped entry — it dispatched it, and what stopped it was ledger's own already-reversed domain check, incidentally:

REQUIRE_THROWS_AS( morph::journal::replay("LedgerModel", mismatched), SchemaMismatchError )
due to unexpected exception with message:
  undo rejected: this transaction has already been reversed

The fingerprint gate never fired at all. Had the swapped payload been one the domain accepted, it would have replayed clean against the wrong fields.

After

LedgerId -> x{ledger.ledgerId}    JournalId -> x{ledger.journalId}    (…all eight)

"(journalId:x{ledger.journalId},ledgerId:x{ledger.ledgerId})"   2:db39897022f6d760
"(journalId:x{ledger.ledgerId},ledgerId:x{ledger.journalId})"   2:bad2849c6137ef9a

Tag naming

ledger.<idName> — rung-namespaced family-then-discriminator, matching the framework's own quantity.usd.2 / tagged.acct. The discriminator is the C++ type name so the mapping stays unambiguous in an entry read years later, and it is spelled in-source rather than derived from glz::name_v.

Two deliberate omissions:

  • No Inner. Tagged declares one because Tagged<std::string> vs Tagged<int64_t> changes the bytes. Here the names already discriminate and each id's underlying type is fixed by its own header.
  • kPayloadFingerprintScheme is not bumped. detail::payloadShape still emits exactly what it did for an unchanged struct; the scheme prefix versions the algorithm, not one application's declarations. Bumping it would invalidate every other rung's journals for a ledger-local change.

Docs — verified nothing goes stale

Checked by grep rather than assumed. examples/ledger/README.md never mentions fingerprints, PayloadShapeTag or replay's mismatch gate. The rung-5 design spec has zero hits for fingerprint / payload shape. docs/spec/journal/journal.md's residual-boundary bullet stays true as a general statement, and its "Every custom-codec type morph itself ships has declared one" was already true — ledger is an example, not shipped framework surface.

No edit was needed to any file owned by PR #312.

On-disk consequence, flagged not hidden

Declaring the tags changes this rung's fingerprints once (UndoTransaction: 2:bb198e6aad4d30a82:db39897022f6d760), so a retained ledger journal stamped by an earlier build now mismatches on replay. That is the documented behaviour of the seam — same class as adding a field, handled by a registered migration or a surfaced SchemaMismatchError — and ledger is a ladder example with no shipped journals. Noted because it is a real effect, not because it blocks.

Verification

ladder_ledger_tests504 assertions / 97 cases, all passing. ctest -L ladder → 100% of 97. Clean under -Weverything -Werror; clang-format clean.

Three files, none in #312's changed set: ledger/core/types.hpp (7 tags via a macro mirroring the existing ..._WIRE one), ledger/core/import_op_id.hpp, and a new tests/test_ledger_payload_shape.cpp. No CMakeLists.txt edit needed — morph_add_rung() globs tests/*.cpp with CONFIGURE_DEPENDS, and ctest discovered it as test #100.

Not run: clang-tidy (off in this configure) and Doxygen (DOCS_SOURCES is include/morph only, so examples/ headers are outside its input).

The same gap exists in the other rungs

grep -rn PayloadShapeTag examples/ was empty before this commit, and ledger's own comments cite bookmarks::BookmarkId and kanban's ProjectId as the shape it copied. Only ledger was verified empirically — the others are inferred from those citations, not measured. Worth a follow-up that measures rather than assumes.

Part of #304 (§B3).

🤖 Generated with Claude Code

Every id this rung carries -- the seven LEDGER_DEFINE_STRONG_ID types and
ImportOpId -- has its own glz::meta naming a value rather than an object.
That is what makes BRIDGE_REGISTER_ACTION on a DTO carrying one compile, and
it is also what leaves morph::model::payloadShape with no reflected members
to decompose: absent a declared PayloadShapeTag each renders as the bare
opaque `x' (morph/core/payload_shape_tag.hpp; docs/spec/journal/journal.md,
"Custom-codec types name themselves"). None of them declared one.

This rung's payloads are largely *made of* ids, so the consequence was not
theoretical. Measured on origin/master before this change:

  UndoTransaction -> (journalId:x,ledgerId:x)                    2:bb198e6aad4d30a8
  ids swapped     -> (journalId:x,ledgerId:x)                    2:bb198e6aad4d30a8
  SetCategory     -> (accountId:x,categoryId:x,ruleId:x,ruleVersion:i4)
  ids swapped     -> (accountId:x,categoryId:x,ruleId:x,ruleVersion:i4)

Exchanging two id fields' types -- what an id rename or a copy-paste in a
later rung produces -- left the fingerprint bit-identical, so replay()'s
mismatch gate had nothing to fire on while the recorded integers decoded into
the wrong slots. The ids are all optional<int64_t> on the wire, so the JSON is
byte-identical across such a swap too: the shape tag is the only place it is
visible at all. examples/LADDER.md names this as the payload-evolution risk
that bites this rung.

Tags are `ledger.<idName>' -- rung-namespaced so they cannot collide with
another rung's, and spelled here rather than derived from glz::name_v, which
is compiler-dependent. They are part of the on-disk fingerprint of every entry
this rung records, so they are an interface: renaming one invalidates every
retained entry carrying that id, exactly as renaming a field does. Declaring
them changes this rung's fingerprints once, now (UndoTransaction moves to
2:db39897022f6d760), which is the same class of change as adding a field and
is handled by the same seam -- a registered migration, or a surfaced
SchemaMismatchError. kPayloadFingerprintScheme is deliberately *not* bumped:
detail::payloadShape still emits exactly what it did for an unchanged struct,
and the scheme prefix versions the algorithm, not one application's
declarations.

The new test file pins both halves: that no two ids share a rendering, that
UndoTransaction's and SetCategory's ids are no longer interchangeable, and
that replay() throws SchemaMismatchError on a real recorded entry re-stamped
with the swapped-id build's fingerprint -- which before this change it did
not, dispatching the entry instead.

New file rather than an existing one, and no CMakeLists edit: morph_add_rung()
globs tests/*.cpp with CONFIGURE_DEPENDS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit 2933e8f into master Aug 27, 2026
37 checks passed
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