Uh oh!
There was an error while loading. Please reload this page.
Slice 3 — guest timeline and attributed decisions - #17
Merged
Conversation
Slice 3 makes source business objects — reservations first — first-class associations on resolved guests, and attributes every decision to the system, a named human, or a named agent. Key design decisions, recorded in research.md: - The object version is the unit of supersession: the newest version's complete person roster determines who is on a booking. Persons are never matched across versions, so a source carrying entity-less persons cannot fabricate reassignments when a guest list shrinks, and guest removal becomes detectable for the first time (R3). - Associations are derived on read, never materialised, so recomputability is structural rather than maintained across four mutation points (R1). - Object identity lives in a record_object companion table, leaving source_record and its immutability trigger untouched (R2). - Do-not-merge rules are lifted, not deleted, so the actor who overrides a steward's split is recorded beside the one who made it (R8). - One paging idiom across the API: /match-reviews and /negative-rules migrate off raw offsets to the same keyset cursor the timeline uses (R9). Amends roadmap note R4-1: its per-person emit-on-change rule becomes roster-complete emission, since a sparse version would read as a booking that lost guests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Source business objects (reservations first) become associations on resolved
guests, answering "what does this guest currently have" rather than only "what
did we ever observe about them". Every decision now names the actor that made
it: the system, a named human steward, or a named agent.
The object version is the unit of supersession. Each version's observations form
a complete roster, and the newest roster alone decides who is on an object —
persons are never matched from one version to the next. Sources carrying
entity-less persons give no id to follow across edits, so any slot-tracking
scheme would have to guess, and would report a reassignment every time a
booking's guest list shrank. The roster model also makes removal detectable:
dropping one of two additional guests no longer reports the other as their
replacement.
New:
- GET /guests/{id}/timeline — current associations, includePast for ended ones,
keyset-cursor paged, ordered by business start
- GET /source-objects/{system}/{type}/{id} — an object's current roster and its
full observation history, which spans guests after a reassignment
- POST /records accepts an optional sourceObject block
Associations are derived on read and never persisted, so recomputability is
structural rather than maintained across four mutation points, and a roster read
mid-delivery self-heals with no convergence logic.
V3 adds record_object (an immutable companion of source_record, leaving its
immutability trigger untouched) and actor columns on merge_event,
negative_match_rule, and api_key. Do-not-merge rules are now lifted rather than
deleted: overriding a steward's split is itself a decision that needs an actor,
and a deleted row has nowhere to keep one. That required replacing the rule
pair's unique constraint with a partial index over active rules — the one
non-additive step, safe pre-release.
Also migrates /match-reviews and /negative-rules off raw limit/offset onto the
same opaque keyset cursor, so the API has one paging idiom. Beyond consistency
this fixes a real wart: under offsets, reviews decided mid-traversal shifted the
remaining rows and clients silently skipped entries.
Amends roadmap R4-1: per-person emit-on-change becomes roster-complete emission,
since a sparse version would read as a booking that lost guests.
198 tests, ArchUnit, PMD, Spotless, and the two-way OpenAPI gate green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>…drifting The matching rules lived in exactly two places, neither of them living: slice 2's spec (a point-in-time record that drifts by design) and the constants in FuzzyMatcher. Nobody reading a review entry, tuning a threshold, or adding a matcher had anywhere to look. docs/matching.md is that reference, keyed by matcher version. Merge events permanently record the matcher that decided them, so the document is append-only: fuzzy-rules-v2 will get its own section and v1 will stay, because an event from 2026 must remain interpretable later. It opens with the layered confidence model — deterministic identifiers at 1.0, probabilistic scoring only above a tenant-chosen threshold, an agent later, a human with the final word — because the individual weights only make sense once it is clear what each layer is entitled to decide. Two things it makes explicit that were previously implicit: - Blocking and scoring use different algorithms for different reasons. Phonetics (Double Metaphone) find candidates because a database index can only answer "equal"; Jaro-Winkler grades them because a band needs a gradient. Conflating the two is easy from outside the code. - Automatic fuzzy merging ships off, provably: the threshold defaults to 1.0 and scores cap at 0.999, so the auto-merge band is empty until a tenant opts in. Worked examples carry the real measured values, including the limits — Müller / Miller block identically and are separated only by birthdate, and Anna / Hannah are string-similar but never block together, so they are never compared at all. Explainability is Constitution IV. The API already returned a per-signal breakdown; without a document saying what the signals mean, that was only half delivered. Also adds three Polish-phase entries to the tasks template, including the org profile README at guestgraph/.github — a separate repository that does not move with this one, and that had already drifted to "Core in development" while slices 1 and 2 were done. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"The original data is sacred" was the only untestable bullet in a list of testable ones — you can check immutable, tenant-scoped, API-first, Apache 2.0; you cannot check sacred. In a principles list the odd one out reads as decoration, which undersells a principle enforced by a Hibernate annotation, a Postgres trigger, and an append-only delete guard. It also restated "immutable" with less precision, and carries a religious connotation that travels badly for readers across Europe working in a second language. The replacement states the consequence instead, which is the part an integrator actually needs: corrections arrive as new records, never as edits. That tells someone how to fix wrong data in their integration; "sacred" only conveyed an attitude toward it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
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 freeto 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.
Source business objects (reservations first) become associations on resolved guests, answering "what does this guest currently have" rather than only "what did we ever observe about them". Every decision now names the actor that made it: the system, a named human steward, or a named agent.
Spec, plan, research and tasks are in
specs/003-timeline-journey/(committed separately in c79cd84 so the requirements are reviewable apart from the code).The central decision
The object version is the unit of supersession. Each version's observations form a complete roster, and the newest roster alone decides who is on an object — persons are never matched from one version to the next.
This is the crux, and it departs from the roadmap sketch. Sources carrying entity-less persons (Apaleo reservations) give no id to follow across edits, so any slot-tracking scheme has to guess, and reports a reassignment every time a booking's guest list shrinks. The roster model also makes removal detectable: dropping one of two additional guests no longer reports the other as their replacement.
What's new
GET /guests/{id}/timeline— current associations,includePastfor ended ones, keyset-cursor paged, ordered by business startGET /source-objects/{system}/{type}/{id}— an object's current roster and full observation history, which spans guests after a reassignmentPOST /recordsaccepts an optionalsourceObjectblockAssociations are derived on read and never persisted, so recomputability (FR-010) is structural rather than maintained across four mutation points, and a roster read mid-delivery self-heals with no convergence logic (FR-010a).
Three things that need an explicit nod
1. FR-007 changed during implementation. Writing the deriver tests showed the spec's successor rule — "name the current holder when the roster has exactly one guest in that role" — would name Zoe as Yara's successor in
[Yara, Zoe] → [Zoe], the exact fabricated transfer this design exists to prevent. It is now a genuine one-to-one handover. The spec commit and the implementation commit disagree on this; the second is right, andspec.mdwas updated in the same commit.2. V3 is not purely additive. It drops
negative_match_rule's pair unique constraint for a partial index over active rules, because rules are now lifted rather than deleted (overriding a steward's split is itself a decision that needs an actor, and a deleted row has nowhere to keep one). Safe only because nothing is released — please confirm you're happy with that rather than letting it slip past.3. Two earlier slices' endpoints changed.
/match-reviewsand/negative-rulesmoved off rawlimit/offsetonto the same opaque keyset cursor, and their contracts changed with them. Scope beyond this slice's stories, taken deliberately (research R9) so the API has one paging idiom while that is still free to change. It also fixes a real wart: under offsets, reviews decided mid-traversal shifted the remaining rows and clients silently skipped entries. This narrows SC-008 to resolution behaviour, which the spec now says.Schema
V3__timeline_and_actors.sqladdsrecord_object— an immutable companion ofsource_record, following therecord_identifier/record_block_keyprecedent sosource_record's immutability trigger stays untouched — plus actor columns onmerge_event,negative_match_rule, andapi_key.Actor type is bound to the credential (the trust boundary);
X-Actor-Idmay name the individual behind a shared credential but can never widen the type.ResolutionEnginetakes no actor parameter at all, so automatic resolution has no code path to being attributed to a person.Verification
er-driftjob passes locallyLocalDevSeederagainst V3A code review pass caught one real bug worth flagging: the timeline's ordering key was not total, so a page boundary between two entries of the same object (a booker who is also the primary guest) silently dropped one. Fixed with a single ordering key shared by the sort, the cursor, and the seek.
Deliberately not in this slice
The FR-011 agent carve-out — an agent may not lift a human's split — is now enabled (both actors sit on one rule row) but not enforced; it belongs with scoped credentials in slice 5. Recorded in
docs/roadmap-notes.mdunder R5-1.🤖 Generated with Claude Code