chore(#2680): fifth sync of main into the epic — #3276's build-up, composed with the type-safety stages - #3362
Merged
thatskiff33-agents merged 18 commits intoSep 12, 2026
Conversation
…er-night output Hand-worked literals for every promo type and scoping rule (own-night assignment, booker-picks-guests, group fixed-nightly, work-party date window on a non-contiguous stay): booking total, per-guest and per-night rates, discount, adjustment, allocations and final price. D3 on #3272 says stage 2 changes no figure a member sees; this file is that proof and is committed first so nothing after it can move a number unnoticed.
…tate (expand only) BookingGuestNightAdjustment: one row per adjustment per target (night for percentage/free-night/fixed-nightly, guest for fixed-amount), signed integer-cent amount with NULL meaning not known, CHECK that exactly one target is set, provenance by redemption (CASCADE) and code (RESTRICT), bookingId for one indexed rewrite. BookingGuestNight.adjustmentsState defaults to UNKNOWN so an omitted-column insert by the draining colour reads honestly. No stored amount changes. Ledger row, verification fixture (default, CHECK and cascade pinned with mutants) and the member-merge relation spec ship with it.
…s through nights Settling a financial review re-runs the promotion over the strands' stored nights (rebaseBookingPriceFromStrands), so it is a promotion writer the plan's list missed; it now records what the engine took off each night, after the repair's own night writes. The snapshot read for mechanical rewrites goes through BookingGuestNight (night-scope rows nested) and the guest-scope rows on their own, so the module never touches a night delegate it does not write. The batch path narrows to the priced branch before it reads the engine's guest list.
…order doubles promo-night-attribution pins what each promo type takes off each night or guest across the promo-guest-scope rules and a work-party window; night-adjustment-write proves the INV-MONEY-029 guard both ways and the snapshot/restore carry; the new census holds the single writer and every promotion-writer pairing in the order the night rows allow; the stage-1 census and the INV-MOD-028 repair census register the state-only writer with a control. Existing transaction doubles gain the recorder's delegates and the promo mock factories gain requiredAdjustmentTargets.
money.md gains INV-MONEY-029 (the reconciliation rule, the deciding grain, never default an adjustment to zero, unknown stays unknown, credit excluded with its one home named) and DOMAIN_INVARIANTS.md its row; the changelog fragment and the size allowances for the nine over-budget files the recorder call had to land in.
The financial-review settle re-bases the booking through the promotion engine and now records the build-up, so its transaction doubles gain the recorder's delegates, and the two 'no night write' assertions narrow to 'no price write' — the adjustmentsState flip is the one in-place night update that path may make.
…-adjustment-buildup
…mber migration, refuse-before-mutate helper, merge dependents (in progress)
…ided, bundled discount shape, recorder outside the waitlist degrade path (in progress)
…canner, redemption-write scan, merge dependents test, INV-MONEY-029 rewrite (in progress)
…ws follow their allocation Owner decision 10 Sep 2026: whether a booking's build-up can be trusted is derived by summing its rows against the recorded promo totals, never stored; BookingGuestNight.adjustmentsState and its enum are gone from the schema, the migration (renumbered 20260913010000 to clear two branches on the old prefix), the fixture, the writer, every caller, the censuses, INV-MONEY-029 and the changelog. deriveNightAdjustmentState is the one home for the reader's answer (KNOWN / NOT_KNOWN / NO_PROMOTION). M4: a member merge that drops a duplicate's colliding allocation deletes that duplicate's adjustment rows on the same redemption in the same step. S1: the row's beneficiary is stamped in calculatePromoDiscountForGuestRates, the branch that decides the allocation. S3: both censuses read one AST night-writer scanner and stage 2 requires every discovered writer to be paired or exempt. S4: the discount carries its targets; PromoChangeResult discriminates on promoEngineRan. S5: the rule is stated once in money.md. C1: the recorder refuses before it mutates and the waitlist calls it outside its degrade path. C4: direct redemption writes are confined to promo.ts. K1/K2/K4/K6: tests for the parked-removal shape, a guest with no night rows, required nightDatesByGuest, own-night FIXED_AMOUNT and a FREE_NIGHTS tie.
…tested and documented, census names the indirect writer F1: a colliding merge leaves each surviving allocation matching its rows per beneficiary while the redemption total still carries the dropped share, so the booking derives as not known until the next engine run; money.md, the drop note and the relation spec say so, and the derive tests pin the state. F2: two waitlist tests — a recorder refusal fails the sweep (no offer, the invariant logged by name, nothing written) while a pricing failure never reaches the recorder and offers the stored snapshot; the guide and booking-modifications.md name the recorder as the one step outside the degrade block. F3: the censuses name member-merge.ts as the acknowledged indirect writer (delegate-name literals censused; it writes no amount) and the docblock, schema and money.md say 'one writer of amounts'. F4: two stale comments in manual-refund-task.test.ts reworded.
… round (2828 -> 2831) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the measured stripper importers The night adjustment writer decoded and shifted stay dates through the retired date-only adapter, which raised the escape-hatch census's production-importer count to 222 against its 221 ratchet. It now reads the @db.Date value through calendarDateOfDateOnlyInstant, shifts whole days with addCalendarDays and re-encodes with dateOnlyInstantOf (INV-CONFIG-002, INV-DATE-019); the census passes at 221 unchanged. The shared night-writer scanner is a second test helper importing the canonical comment stripper, so the INV-SSOT-004 guard names it, its regex accepts 'two test helpers' exactly, and both publishers now say the measured 74 test files, two test helpers and one CI script.
Seven conflicts. Three are code, and all three are the same shape: `main`'s #3276 night-adjustment build-up and the epic's type-safety migration edited the same lines. Each takes main's SUBSTANCE in the epic's SHAPE, because the two are orthogonal. `policies/pricing.ts` — main added a `targets` array and two destructured fields to a counter loop the epic had replaced with a slice prefix. Kept the slice and added main's fields, after checking the body reads only the destructured names and never the index. `booking-create-promo.ts` — kept the epic's refusal when a guest has no per-night rates, and took main's plain index read for `nightDatesByGuest`, which is declared `Date[][]`, so the optional chain the epic wrote was guarding a parameter that cannot be absent. `member-merge.ts` — kept the epic's typed `mergeDelegate` accessor. main wrote its new dependents block against an inline `Record<string, ...>` cast of the transaction, which is the exact shape the type-safety stage removed from six other sites; taking it verbatim would have left one untyped hole in a file that no longer has any. That block now routes through the same accessor. The comment-stripper's importer population is **76 test files, two test helpers and one CI script**, measured on the composed tree. Neither side had it: the epic said 75 and one helper, `main` said 74 and two. main's #3276 added the second helper; the test-file count is what the composition actually holds. `INV-MONEY-029` arrives from main and its index row was 17 words against a 12-word cap that has no register and no exceptions, so the row is compacted the way `INV-MOD-028`'s was. The RULE is untouched — only the one-line pointer. `booking-modifications.md` takes the epic's empty side: main's hunk is the "what reads a re-based strand total" passage still sitting inside its unsplit `INV-MOD-028`, and the epic already holds that passage exactly once, under the `## INV-MOD-038` the fourth sync gave it. Taking main's copy would have duplicated it in the same file. Three NEW `noUncheckedIndexedAccess` diagnostics arrived with main's code, in `promo.ts` and `work-party.ts` — a tree the earlier stages had already cleared. Fixed rather than recorded: both filter the array instead of mapping its indexes, so there is no lookup left to prove. In `promo.ts` that also removes a non-null assertion and makes the two parallel vectors parallel BY CONSTRUCTION, which is the property its own comment says must hold. Two word-budget rows: `INV-MONEY-029` joins the ratchet at 667, and `INV-MOD-005` is re-measured from 351 to 381. The second is a GROWTH, which the shrink-only rule forbids — and the register now says why it stands: the entry is byte-identical to main's, so main grew it where no register was watching and the merge took that side without a conflict. The shrink-only rule still binds every row an epic child touches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
… real collision Eleven recorded lengths no longer matched their files. None of that is this branch's doing: `main`'s #3276 night-adjustment build-up grew the same booking, promo, member-merge and pricing modules the type-safety stages had already grown, so each is longer than either change left it. Re-measured off the merged tree rather than adjusted. One genuine one-file-one-allowance collision, and this one is real rather than the mid-merge illusion the last sync taught me to expect. Two EPIC children grew `public-booking-requests-panel.tsx`: #3342 rendered its Internet Banking amounts in the configured currency, and #3350's type-safety stage fixed the money accessor in it. Both fragments are new since `main`, so both are live. #3350's entry survives as the single home, because it can state both causes accurately, and it now does. #3342's entry for that file is removed and replaced by a pointer saying where the number lives and why — its own reasoning stays where it was written. Recording the distinction, because the two look identical in the gate's output and I resolved the wrong way once already: a collision between two EPIC children is real and one entry has to go, while a collision that names a fragment already merged to `main` is an artifact of running the gate on a staged, uncommitted merge, and disappears the moment the merge is committed and the base moves. Gate reports OK: 342 production files changed since origin/main, all growth declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
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.
Linked Issue
Summary
The fifth and final sync of
mainintoepic/2680-mep, bringing the epic level so its own pull request intomainresolves once rather than repeatedly.main's #3276 night-adjustment build-up is the substance of it.Seven conflicts. Three are code and all three are the same shape — #3276 and the epic's type-safety migration edited the same lines — so each takes main's substance in the epic's shape, because the two are orthogonal.
policies/pricing.ts—mainadded atargetsarray and two destructured fields to a counter loop the epic had replaced with a slice prefix. Kept the slice and addedmain's fields, after checking the loop body reads only the destructured names and never the index.booking-create-promo.ts— kept the epic's refusal when a guest has no per-night rates, and tookmain's plain index read fornightDatesByGuest, which is declaredDate[][]: the optional chain the epic wrote was guarding a parameter that cannot be absent.member-merge.ts— kept the epic's typedmergeDelegateaccessor.mainwrote its new dependents block against an inlineRecord<string, ...>cast of the transaction, which is the exact shape the type-safety stage removed from six other sites. Taking it verbatim would have left one untyped hole in a file that no longer has any, so that block routes through the same accessor.The comment-stripper's importer population is 76 test files, two test helpers and one CI script, measured on the composed tree. Neither side had it: the epic said 75 and one helper,
mainsaid 74 and two. #3276 added the second helper; the test-file count is what the composition actually holds.INV-MONEY-029arrives frommainand its index row was 17 words against a 12-word cap that has no register and no exceptions, so the row is compacted the wayINV-MOD-028's was. The rule itself is untouched — only the one-line pointer.booking-modifications.mdtakes the epic's empty side.main's hunk is the "what reads a re-based strand total" passage still sitting inside its unsplitINV-MOD-028, and the epic already holds that passage exactly once under the## INV-MOD-038the fourth sync gave it. Takingmain's copy would have duplicated it in the same file.Three new diagnostics arrived with main's code, and are fixed rather than recorded
promo.tsandwork-party.tslanded threenoUncheckedIndexedAccessdiagnostics into a tree the earlier stages had already cleared. Both now filter the array instead of mapping its indexes, so there is no lookup left to prove. Inpromo.tsthat also removes a non-null assertion, and makes the two parallel vectors parallel by construction — which is the property that code's own comment says must hold.Decisions, and why
Two word-budget rows moved.
INV-MONEY-029joins the migration ratchet at 667 words.INV-MOD-005is re-measured from 351 to 381 — and that is a growth, which the register's shrink-only rule forbids. It stands, and the register now says why: the entry is byte-identical tomain's, somaingrew it where no register was watching and the merge took that side without a conflict. Rewording an owner-decided rule inside a merge resolution is the thing this epic has consistently declined to do. The shrink-only rule still binds every row an epic child touches.One real size-allowance collision, and one that only looked like one. Two epic children grew
public-booking-requests-panel.tsx— #3342's currency change and #3350's type-safety fix — so both fragments are live and one entry had to go. #3350's survives as the single home because it can state both causes, and #3342's is replaced by a pointer. That is genuinely different from the eleven stale numbers beside it, which weremain's growth of files the epic had also grown, and from the mid-merge illusion the fourth sync taught: a collision naming a fragment already merged tomainis an artifact of running the gate on a staged, uncommitted merge and disappears once the base moves.Pros and cons
The gain is that the epic is level with
mainand its own pull request has one resolution to do rather than several, withmain's newest money work composed against the epic's type-safety work under test. The cost is one more oversize invariant entry carried as declared debt and one recorded growth that the register had to be honest about.Risk Level
Medium rather than Low: three of the resolutions are in money-path code, and one of them changes how a promo's per-night rates and dates are filtered.
Changed Areas
Tests Added Or Updated
None added. Every line this branch authors is a conflict resolution or a recorded measurement; the behaviour under test is
main's and the epic's, both already covered. The existing suites are the check on the composition — see below.Validation Commands Run
npm run docs:indexcheck— passes, 626 invariant ids.npm run docs:linkcheck— 657 Markdown files, all links and anchors resolve.npm run typecheck— clean across all three projects.npm run typecheck:nuia— 52, none new and none stale, after fixing the three that arrived.npm run lint— 0 errors, 48 pre-existing warnings.npm run knip— exit 0.npm run quality:budget— OK, 342 production files changed sinceorigin/main, all growth declared.npx vitest relatedover all 53 changed code paths, in nine chunks.Every failure in those two test passes was a timeout, and I checked rather than assumed. Seventeen
Test timed out in 5000msacross the module-graph run and one in the census run, concentrated in suites that walk the tree from disk while sharing a worker pool with up to 3,500 other tests. Re-run in isolation, the seven nearest my changes — the promo, sold-price, group-discount, notify-choice, batch-modify, club-time and stay-range suites — pass together in 25 seconds, 173 tests; the census chunk passes fully on its own at 30 files and 735 tests. One case looked like a real assertion (expected "vi.fn()" to be called at least once) and is not: it is the test immediately after a 5000 ms timeout in the same file, and the file passes alone.Commands Not Run And Why
npm testand the build: PR CI owns both, and this repository has measured that running the full suite locally on Windows red-lights suites that pass in isolation.Screenshots Or UI Evidence
N/A.
Security And Privacy Impact
None. No auth, session, token, permission or audit-visibility rule is touched by any resolution.
Data Integrity Impact
The three code resolutions all preserve
main's behaviour. The one worth naming ispromo.ts: filtering both vectors by the same position set keeps a guest's per-night rates and their dates parallel, which is what stops an adjustment being attributed to the wrong night on a stay the promo window only partly covers. That was already #3276's intent; this makes it structural rather than dependent on two index maps agreeing.Concurrency And Lock Impact
cron, or concurrency-sensitive writer changed.
main's writers and the epic's type-safety edits, neither of which touches a lock site.mainand owns the night-adjustment build-up; its own lock declaration governs that code and nothing here alters it. TYPE-SAFETY 3 (#2800): every noUncheckedIndexedAccess diagnostic in non-test src/lib, 818 to zero #3338, feat(#2799): the noUncheckedIndexedAccess ratchet, and the policy/capacity foundations under it #3310 and feat(#2801): clear noUncheckedIndexedAccess from src/app and src/components, 255 to 52 #3350 are the epic-side type-safety stages that touched the same files, all type-only at the conflict sites. Compatibility evidence is mechanical: the nuia ratchet back at 52 with none new, typecheck across three projects,vitest relatedover every changed path, and all 302 disk-scanning censuses by name — includingmain's own newINV-MONEY-029build-up census, which passes against the resolvedpromo.ts.None, or justify the bounded exception fromdocs/CONCURRENCY_AND_LOCKING.md): None.Single Source Of Truth
member-merge.ts, and the single## INV-MOD-038home for the passagemainstill carries inside its unsplit entry.main's side ofbooking-modifications.mdwould have put the same passage in the file twice. Thepromo.tsresolution replaces two index maps that had to agree with one filter over a shared position set, which is a structural fix rather than a guard.Payment Or Accounting Impact
No amount is calculated differently.
main's promo build-up arrives unchanged in substance.Migration Or Deployment Impact
None authored here.
main's #3276 migration arrives with it and ismain's.Docs Updated
docs/DOMAIN_INVARIANTS.md—INV-MONEY-029's index row compacted to the 12-word cap; the epic's compactedINV-MONEY-028row kept.docs/invariants/single-source-of-truth.md— importer population re-measured to 76.docs/invariants/WORD_BUDGETS.md—INV-MONEY-029recorded at 667,INV-MOD-005re-measured to 381, and the register now states that a row can grow when the growth arrived frommain, and why the shrink-only rule still binds this branch.Changelog Entry
changelog.d/<pr-number>-<slug>.md): none authored here. This branch ships no behaviour of its own; the fragments in the diff arrive withmainand already describe its changes.Residual Risks
None.
Stated limits
Not exercised against a real Postgres: no resolution alters a query shape, a lock key or a transaction boundary, so those harnesses have nothing new to arbitrate.
Manual Checks Required
None.
Safety Confirmation
AGENTS.md"Completion and Merge" risk gate: branch maintenance into the epic's own integration branch, eligible for autonomous merge once the required checks pass. The epic's merge intomainremains the owner-gated one.🤖 Generated with Claude Code
https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV