Skip to content

Slice 3 — guest timeline and attributed decisions - #17

Merged
robertblust merged 4 commits into
mainfrom
003-timeline-journey
Aug 21, 2026
Merged

Slice 3 — guest timeline and attributed decisions#17
robertblust merged 4 commits into
mainfrom
003-timeline-journey

Conversation

@robertblust

Copy link
Copy Markdown
Contributor

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, includePast for ended ones, keyset-cursor paged, ordered by business start
  • GET /source-objects/{system}/{type}/{id} — an object's current roster and 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 (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, and spec.md was 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-reviews and /negative-rules moved off raw limit/offset onto 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.sql adds record_object — an immutable companion of source_record, following the record_identifier / record_block_key precedent so source_record's immutability trigger stays untouched — plus actor columns on merge_event, negative_match_rule, and api_key.

Actor type is bound to the credential (the trust boundary); X-Actor-Id may name the individual behind a shared credential but can never widen the type. ResolutionEngine takes no actor parameter at all, so automatic resolution has no code path to being attributed to a person.

Verification

  • 198 tests, ArchUnit, PMD, Spotless, and the two-way OpenAPI gate over all three feature contracts
  • ER diagram regenerated; er-drift job passes locally
  • Quickstart walked end to end against a running instance, including LocalDevSeeder against V3
  • SC-006 measured at ~2,500 observations (the sizing research R1 argues from): first page well under 1s

A 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.md under R5-1.

🤖 Generated with Claude Code

robertblustand others added 3 commits August 21, 2026 15:14
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>
@robertblust
robertblust merged commit 86559a0 into mainAug 21, 2026
2 checks passed
@robertblust
robertblust deleted the 003-timeline-journey branch August 21, 2026 17:33
Sign up for freeto 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

@robertblust