ledger: give every strong id a PayloadShapeTag, so a swapped-id payload stops fingerprinting identically - #320
Merged
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap, reproduced before fixing
include/morph/core/payload_shape_tag.hpp:10-16states the problem the seam exists for: a type carrying its ownglz::metahas no reflected members to decompose, so it renders as the bare tagxand 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 asx:So two structurally different actions fingerprinted identically:
SetCategory{AccountId, CategoryId, RuleId, …}behaves the same way.LADDER.md:139-142names 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: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
Tag naming
ledger.<idName>— rung-namespaced family-then-discriminator, matching the framework's ownquantity.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 fromglz::name_v.Two deliberate omissions:
Inner.Taggeddeclares one becauseTagged<std::string>vsTagged<int64_t>changes the bytes. Here the names already discriminate and each id's underlying type is fixed by its own header.kPayloadFingerprintSchemeis not bumped.detail::payloadShapestill 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.mdnever mentions fingerprints,PayloadShapeTagor replay's mismatch gate. The rung-5 design spec has zero hits forfingerprint/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:bb198e6aad4d30a8→2: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 surfacedSchemaMismatchError— and ledger is a ladder example with no shipped journals. Noted because it is a real effect, not because it blocks.Verification
ladder_ledger_tests→ 504 assertions / 97 cases, all passing.ctest -L ladder→ 100% of 97. Clean under-Weverything -Werror;clang-formatclean.Three files, none in #312's changed set:
ledger/core/types.hpp(7 tags via a macro mirroring the existing..._WIREone),ledger/core/import_op_id.hpp, and a newtests/test_ledger_payload_shape.cpp. NoCMakeLists.txtedit needed —morph_add_rung()globstests/*.cppwithCONFIGURE_DEPENDS, and ctest discovered it as test #100.Not run: clang-tidy (off in this configure) and Doxygen (
DOCS_SOURCESisinclude/morphonly, soexamples/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 citebookmarks::BookmarkIdand kanban'sProjectIdas 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