The day count was the length of the labelled run inside the calendar
grid, so the same day read as day 11 over a month and day 23 over three:
the run was cut off at the window's edge rather than at the cycle's
start. That was a latent contract question while every caller asked for
the same ninety days, and it stopped being latent when the month grid
began reading its own month, which is thirty days by construction.
The verdict now anchors on the period start the record holds and falls
back to walking the grid only when no caller supplied one, so the ring's
arcs stay grid-derived (they are about what can be drawn) while the day
is about what the person logged. The overdue ceiling then measures the
real elapsed count too.
Covers D-list edge 15, both shapes: a window that clips the run and a
window that excludes the cycle start altogether.
The constants make ovulation minus five through ovulation plus one, and
the comment above them claimed a six-day window. Wilcox's six days are
the five before ovulation plus the day itself, measured against a KNOWN
ovulation day. Ours is estimated, usually by back-calculating a luteal
length from a median cycle, so a window drawn tight to the estimate
loses its last day whenever the estimate lands early, and the ovum stays
viable for most of a day after release besides.
The direction is decided by who reads the window. It is surfaced to
people trying to conceive and to people avoiding it, and for the second
group a window one day short is a window that misses. The constants keep
the conservative reading and the comment now states it, with the note
that changing either one is a coordination matter because the iOS client
reimplements the same spec.
The width is pinned by a test rather than left to be re-read off the
numbers the code already uses.
Two defects put a record into a state that looks, from the app, like no
period was ever logged. Both are fixed going forward; neither repairs
the rows already made, and neither is visible from the surface it broke.
A closed torso is a live cycle carrying an end date with no live cycle
after it. A cycle is closed by its successor, so closed with no
successor means the successor is gone, and a record whose last cycle is
closed has no open cycle for the engine to forecast from. The signature
is exact and needs no audit trail.
A period with no cycle row is bleeding that only ever existed as a
day-log, from the flow chips, the bulk drain or an import. The engine
reads cycle rows, so the record holds periods and reports none.
The report imports the bleeding-day rule from the write path rather than
restating it, so it and the app cannot drift on what counts as a period
start. Checked against seeded records: it names the torso, names the
three flow-only starts while passing over a day two and a spotting day,
and says nothing about a healthy record.
Nothing is written.
MenstrualCycle rows were only ever written by the one-tap period
boundary. Every other way of recording bleeding wrote a day-log and
stopped there: the log sheet's flow chips, the bulk drain, an Apple
Health export full of menstrual-flow samples. The engine reads cycle
rows, so a person could log every period they had and the module still
held that none had happened. No cycle length, no forecast, an empty
chart, and an offer to log a first period. An Apple Health user who
never found the one-tap button had the whole module dead.
A first bleeding day now opens the cycle it belongs to, through one
shared seam in the day-log write helper, so the single capture, the
bulk drain and the Apple Health fold all infer a start the same way and
the one-tap boundary's own write lands on a date that already has a
cycle, which the rule declines.
The rule is conservative, because a false start corrupts every length
derived after it. Spotting does not open a cycle: it is as often the
tail or the herald of a period as its first day. Bleeding flagged as
between periods does not, since that flag exists to say so. Neither does
a bleeding day within the hard physiological minimum of an existing
start, which is what keeps day three of a period, or a forgotten day
two, from starting a cycle of its own without needing a contiguity
check. A genuinely short cycle is not inferred from flow alone; the
one-tap start still opens one on any date.
An edit is symmetric. Setting a flow opens the cycle; clearing it, or
flagging it as between periods, removes the cycle that day opened and
re-opens the one before it. A cycle that an edit can create and not undo
leaves the record holding a period nothing on the day says happened.
- the period route's start arm and the flow inference now share one
boundary helper, so a start means the same thing however it is entered
- back-dating re-anchors both neighbours, as the one-tap start does
- integration: eleven cases through the real routes, of which five are
about what must NOT open a cycle
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.
The grid now reports the month it moved to and gets its own bounded read
for it. The anchored window is untouched, because every other surface on
the page means today and the route caps a span at 400 days, so a union
with a month two years back would simply be refused.
A write also says so now. Saving, deleting and both period boundaries
raise a confirmation; the sheet's inline strip already named a failure.
Between the invisible month and the silent success there was no way to
tell a save that worked from one that never happened.
The sheet header carries the date it is writing to, and the one-tap
boundaries drop the word today when the day is not today. It always
wrote to the selected date; saying otherwise made a correction read as a
mistake.
The gear beside the log button is gone. It was a second door to the tab
directly below it and no other page carries one in that corner.
- calendar days carry isCycleStart, so the delete dialog can say that
removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
read, and comes back complete on a window over its month
- new strings in all six languages
The cold-start gate suppresses the calendar's phase band until three
cycles are observed, because a population-framed phase word off one or
two of them is not earned. The verdict read the day count off those same
labels, so the gate took the count with it: a record holding three
logged period starts resolved INSUFFICIENT_DATA and rendered an empty
ring under an offer to log a first period.
The count is not the claim the gate is about. It is the number of days
since a start the person entered themselves. The verdict now takes that
start from the cycle rows and falls back to counting from it whenever
today carries no phase, with the same overdue ceiling and no phase
claim. INSUFFICIENT_DATA is left for the case that really is one: no
period start on record at or before today.
The sheet's day header was already written for this state and could
never reach it — it renders a bare day number when no phase is
trustworthy.
- verdict round trip covers the still-learning day count and its overdue
side through the real route
- unit tests pin the anchor path, the absent-from-grid path, and that a
future-dated start is not a cycle anyone is in
Opening a cycle closes the one before it: the start write stamps the
previous cycle's endDate and lengthDays, and a back-filled start takes
its own end from its successor. Nothing undid any of that. Deleting the
start left the previous cycle as a closed torso holding an end date from
a cycle that no longer existed, and a record whose last cycle is closed
has no open cycle to forecast from — the prediction went away and there
was no way to bring it back from the app.
Both removal paths now re-derive the boundary from the cycles that
remain: a cycle ends the day before the next one begins, and with no
next one it is open again. That is a re-derivation rather than an undo
log, so it is correct for a history that came from an import or a
restore, and running it twice changes nothing the second time.
Deleting the day-log that opened a cycle now removes that cycle too.
The one-tap start writes both rows and the log sheet's delete is the
only way the web offers to take it back; leaving the cycle standing was
how the broken state survived the undo.
- soft-delete and re-anchor run in one transaction
- the audit row names the neighbour's moved boundary, the only part of
the write the tombstone cannot reconstruct
- round trip through the real routes: photograph the cycle rows, log the
start, remove it either way, compare field by field — including that
the forecast comes back
The engine ended the open cycle's phase window at the predicted next
start, so the day after a missed forecast carried no phase at all and
the verdict resolver read that as today falling outside every known
cycle window. An ordinary three-days-late period therefore reported
INSUFFICIENT_DATA: no cycle day, no phase, an empty ring and the words
insufficient data, while the reminder cron was pushing a period-confirm
prompt for the same day. OVERDUE was only reachable when the engine
itself forecast a cycle longer than the grace window.
The open cycle now runs to the predicted start or through today,
whichever is later, and stops the moment the next start is logged. The
count is bounded on the far side where it already was: past the typical
length plus its grace window the verdict degrades to OVERDUE and says
how late the period is.
The extension pins the open cycle's ovulation anchor to the predicted
length. Left to the span, an unconfirmed ovulation is back-calculated
from the window's own length, so a window that grows with the delay
would walk the estimate and every phase boundary drawn from it.
- buildPhaseCycles, phaseForDate and buildPhaseDayMap take the user's
today; the insights route and the coach snapshot pass it
- verdict round-trip covers the predicted start day, three days past it,
past the grace window, and the case where a new start closes the run
The heart-rate card hid itself whenever the server resolved no curve.
That is right for a session with no heart rate at all, but wrong for the
common case it also covered: a ride logged from a chest strap through a
provider that reports an average and a peak and nothing in between. The
stats grid shows those two numbers, the card next to them renders
nothing, and the page reads as if it has no heart-rate section rather
than as if the shape behind the numbers is not on record.
Three states now. A curve when there is one; a named absence when the
session carries heart-rate figures without a profile; nothing at all when
nothing was ever measured, since there is no absence to report there and
an empty card would be a shell.
The min-to-max envelope shipped as a stacked pair: a transparent base at
the bucket minimum plus a `max - min` delta above it. Stacked series are
measured from zero — recharts derives a stacked axis domain from the
stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10`
resolved to -10 rather than to the bottom of the band. A session spent
between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of
the effort, which is the entire point of the curve, squashed into the top
quarter of the plot. It only happened on the dense sessions the envelope
gate lets through, which are the ones with the most shape to lose.
The band is now one range area over the bucket's own `[min, max]` pair.
No stack, so the domain stays on the bpm values that are in the data.
The axis domain is computed rather than inferred, because the session's
peak has to be inside it: recharts discards an out-of-range reference
line instead of clamping it, and a reported peak is a single reading
while the curve plots bucket means, so a peak marker over a data-derived
domain would go missing on most sessions that have one.
Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
`durationSec` is a denormalised duration, and not every source reports
the elapsed one. Strava sends `moving_time` while the row's `endedAt` is
`startedAt + elapsed_time`, so a ride with twenty minutes of stops
carries a duration twenty minutes shorter than its own span. The curve
folded against that duration, which dropped every reading after the
moving-time mark: the tail of the session vanished with no gap and no
marker, and the detail page showed a complete-looking profile of an
incomplete session.
Fold over the span instead, with the duration as the floor for rows
whose `endedAt` is not after their `startedAt`. The bucket width tracks
the span too, so a stop-heavy ride keeps the same on-screen density as a
continuous one of the same length.
The reconstruction path also gets an integration suite over the real
route and a real Postgres. Two boundaries decide whether it is honest
and neither was provable against a mocked client: the query pads by five
minutes so a boundary reading still lands in the first or last bucket,
and a reading inside that pad must not extend the curve past the finish;
the measurement read is owner-scoped, and another account's pulse at the
same minute must not enter this curve. Both fixtures place a reading on
the far side with a bpm value no in-window reading has, so a leak shows
up in the numbers rather than only in a count. Verified by breaking each
boundary in turn and watching the matching assertion fail.
pdfjs-dist 6.1.200 and a transitive 5.6.205 both sit in the affected range
of a flaw that lets a crafted PDF run JavaScript inside the renderer. The
document vault feeds it every uploaded PDF, so pin the direct dependency to
6.2.108 and add a bounded override so the transitive copy lands there too.
The override stops short of pdf-parse's 5.4.296, which predates the flaw and
drives the local text-extraction path.
The runtime image ships every pdfjs-dist copy in the store, not just the one
that gets loaded, so an unused transitive copy still counted.
Hoisting in the image picked whichever copy the filesystem listed first, and
more than one copy reaches it, so the version named in package.json was not
reliably the one the renderer resolved. Carry the resolved version forward
from the build stage, select it by name, and fail the build when it is
missing rather than leaving another copy in its place.
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.
Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.
Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.
The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.
The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.
`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
The hero line said a pulse was well above its 30-day average today. Nothing
had measured a pulse for five days. The day-signals card flagged the same
reading, and the baseline-drift card said the value was above the usual range,
present tense, on a day that held no reading at all.
One class, and it sits in the data preparation rather than in any of the
sentences. Each surface was handed "the freshest stored value" with no notion
of when it was taken, so each of them assumed now. The age was reachable — the
aggregates carry `newestDaysAgo` inside their coverage object — but it read as
a statistics footnote rather than as the fact that governs the tense of every
sentence about the metric, and none of the three consulted it.
A reading is material for a claim about today when it comes from today or from
yesterday. One day rather than zero: a vital taken at 23:40 and read at 00:10
is the same reading it was half an hour earlier. Two is not, because by then a
whole day has passed with nothing in it and the sentence would be describing a
gap.
The rule now lives beside the data. The vital deviations carry the age of the
reading they were computed from, and the two deterministic consumers apply it:
the day-signals flag counts only current vitals toward its count, and the
baseline-drift card surfaces only current deviations. Stale vitals stay in the
list with their age, so the anatomy view still shows them and nothing silently
disappears; the dated level shifts were never today claims and are untouched.
For the narrated surfaces the same fact is stamped onto every metric block of
the Coach snapshot before anything reads it, so the age travels with the
numbers into the hero, the briefing, the Coach reply and the retrieval tools
instead of each of them guessing. A block narrated off a stale series also
lands in the wide event, which is where it should have been visible all along.
The baseline-drift card reported a pulse in the hundreds of millions against a
usual range in the tens of thousands. Neither figure is a heart rate, and both
came from the same place.
The application declares a plausibility domain per metric, and every
interactive, import, MCP and Telegram write validates against it. The
derivation layer did not ask. So a single stored value outside that domain — a
provider unit-decode slip, or a row from a writer that never had the gate — was
folded into the day mean, carried by the median into the band, and then held
against itself as "currently". The two numbers in the sentence were wrong by
different amounts, which is why the line read as nonsense rather than as an
exaggeration.
Both readers now refuse a value the application has already declared
impossible: the per-day means behind the band, and the latest-day mean the
deviation reads. A day left with no plausible reading contributes nothing
rather than a fabricated point, and the vital drops out of the comparison — the
coverage floor already knows how to report an absent day. The rollup branch
applies the same rule to a bucket mean, since the rollup writer averages
whatever is stored. Everything downstream of `readDayMeanSeries` inherits it,
which is every consumer of the usual range: the band strip, the drift card, the
changepoint detector, readiness, trajectory, the coach read.
The card also printed raw doubles. Each figure now renders at the metric's own
decimal precision through the shared formatter, so a mean reads as a number
rather than as a float and follows the reader's number convention.
Left open, and recorded for a separate pass: the provider sync writers do not
enforce the domain on the way in, so the impossible rows keep landing.
Marking a missed dose taken from the Verlauf ledger showed the row flip to
taken and then fall straight back to missed, with the entry nowhere on the day
it was made for.
The ledger posts the displayed slot's own anchor as `scheduledFor` and means
"record THIS dose". The route resolved the slot by window band from `takenAt`
instead, which answers a different question: which slot the recording moment
falls into. Backfilling an older slot matches no band, so the write fell
through to the standalone insert and landed as an ad-hoc row anchored at the
recording moment. The named slot stayed unserved, the optimistic row was
overwritten by the authoritative refetch, and the dose sat under today with no
sign of where the user had put it.
A named instant that is a real slot of this medication now decides the binding,
validated against the same band anchors the "diesem Slot zuordnen" pin uses, so
an arbitrary client instant still records ad-hoc. A slot that lies ahead of the
take is still refused by the existing forward guard, so a late-morning dose
cannot consume the evening slot. When the naming and the clock disagree the
binding rests on the user's choice, so the row stores it as such and the read
ledger anchors it there rather than dropping back to ad-hoc; an ordinary
on-time take resolves to the same anchor either way and is untouched.
The tests drive the shipped POST and the shipped dose-history GET, and assert
the persisted row and the ledger read back from it.
Maximizing the Coach drawer hands the conversation to /coach and carries
?doc= with it, so browser-Back can rebuild the sheet. That is the one
close-shaped transition whose whole point is to LEAVE the selection where
it is, and the close path has always returned early for it.
The dropped-traversal check added beside that close did not know. It saw
a closed sheet and a selection still in the address bar, which is exactly
the state it exists to repair, and it repaired away the return URL a
quarter of a second after the close had deliberately preserved it.
Whether it won that race was a question about how fast the machine
navigated, so it surfaced as one flaky run on the narrow viewport rather
than as a broken feature.
Both sides read one named predicate now, and the check is skipped when
the close kept the selection on purpose. Proved by driving the grace
window to zero, which is the worst case the timer can have: before, the
maximize never even reached /coach, three attempts out of three; after,
it passes there and the whole documents family passes with it. Twenty
consecutive runs of that family on the narrow viewport are clean.
The service worker writes its data-cache entry after the response has
been handed to the page, so a shell that is ready is not evidence that
the write has landed. Reading the cache once therefore sampled null on a
loaded machine, and the positive control failed saying there was no entry
about a cache that was about to have one.
Polled on the entry itself. It waits for the signal rather than for a
number of milliseconds, and it still fails if the entry never appears,
which is the whole reason the control is there.
The scope mirror is watched so a switch this browser did not begin still
holds every shell until /me reconciles it: another tab on an older
bundle, the referential action behind an account deletion, an operator.
It recognised its own by comparing the mirrored scope against the scope
it was switching to, and that is not a true test. fetchMe mirrors
whatever record the server currently reports and it runs DURING a switch,
both when postSwitchWithReconcile reconciles a lost compare-and-set and
when a second tab reloads beside the initiator. At that moment the server
still reports the record being left, so the write did not match and was
read as somebody else's switch.
What followed was silent. A foreign write publishes a new transition id,
and every terminal call opens by checking the id it was handed, so the
commit that followed was dropped without a trace and the expected scope
was left naming a record no /me would ever confirm. Nothing could release
the hold, and both tabs sat behind the hydration gate until the stored
transition aged out thirty seconds later.
That is the intermittent browser failure that has been carried through
three rounds as a canary and never explained: a peer reload racing a
switch, a gate that never lifts, and a run forty-six seconds long made of
a fifteen-second wait and a thirty-second one. On the selection that
provokes it, eight runs failed three times before and twenty failed none
after, and the runs that used to take fifty-seven seconds now take
twenty-five.
A write is foreign only when nothing of this browser's own is in flight.
Ignoring it while held costs nothing, because a held browser is already
in the state that branch would install. The rule is a named function so
it can be checked in a suite that has no DOM, and the property the guard
exists for is pinned beside it: a response that disagrees with a
committed scope is stale and still must not release.
Both failed on CI and neither failure was about the product.
The loading-state scan held /api/auth/me open with a fixed sleep in the
route handler. Three things follow from that and all three are wrong: the
hold ends 1500 ms after the first request whatever the axe scan is doing,
so the state being scanned is not the state the test names; every refetch
queues another sleeping handler, so how many are live is a property of
the machine; and a handler still sleeping when its request is cancelled
answers route.continue with "Route is already handled", which is what the
runner reported. It now holds on an explicit gate released at a point the
test chooses, counts what it held so a page that was merely slow cannot
pass for a page it held, and asserts the gate goes away on release, which
is the exit control the entry assertion never had.
The labs doorway proved that no OCR probe fires by waiting for that
request with a 500 ms timeout and expecting the rejection. An instrument
whose pass condition is its own failure has to be armed early and awaited
late, and in between it is an unhandled rejection that lands on whichever
test is finishing. Its window was wrong too: the timer started before the
switch was clicked, so on a slow runner it could expire before the labs
page existed. A request listener replaces it, covering everything from
before the click to after the record read landed, with the domain's own
read asserted in the same set so an absence cannot be a statement about a
matcher that stopped matching.
Closing a card-opened document asks the browser to go back, so the entry
the selection pushed is consumed and the reader's next Back reaches the
page before it. A traversal is a request, not a guarantee: one asked for
while another is still settling can be dropped, and nothing raises when
it is. The sheet closes, React stays consistent, and the address bar goes
on naming a document nobody has open, so a reload or a shared link
re-opens the sheet that was just closed.
Pressing Escape straight after the browser's Forward button is exactly
that shape, and it reproduces: ten repeats across both viewports failed
two of twenty with the URL still carrying the id, and none of twenty once
the close verifies itself. The check is guarded by the document id, so a
traversal that landed on a different selection is left alone, and it is
cancelled the moment the sheet re-opens.
The browser test that found it could not say so. It asserted the sheet
was closed through a locator matched on the document TITLE, and the sheet
falls back to an untitled label while its detail read is pending, so the
assertion also passed on an open sheet showing a skeleton, resolved
immediately instead of waiting, and pushed the failure into a URL poll
ten seconds later. It now asserts the dialog role, which is what the
neighbouring deep-link test already does.
The dead-code gate has been red on this branch since the sharing refusal
contracts landed. Neither name is new and neither was noticed, because
knip is not in the local gate.
consentRequiredResponse lost its last caller when every delegable route
started sharing one 403 description through recordRefusal(); the AI
consent sentence it wrapped is still used and stays, with a note at the
constant saying why there is no response object beside it any more.
mutateThenRestore was written with the sharing security fixtures and has
never been called.
No ignore entries: an export with no consumer is the thing the gate is
for.
The operator page still said every grant is read-only, one release after
the write level shipped, and the table of what a grant cannot reach
predates both the manage level and managed profiles.
Rewritten against the frozen behaviour: the eight sections a grant can be
narrowed to and what each opens, the three levels and what each adds, the
step-up that is unconditional on a managed profile and conditional on an
ordinary invitation, and what a guardian may keep for a record that has no
login of its own. The AI row is corrected rather than dropped: a manage
grant reads insights that already exist, and no level causes an outbound
provider call.
Every claim on that page was read out of the code before it was written
down. The one about backups was cut instead, because it was not.
Zod 4's .meta() is immutable: it returns a new schema carrying the
metadata and registers that one. Four request bodies in the sharing
family called it as a statement and dropped the result, so the route
table went on referencing the bare schema, the bodies were emitted
inline and anonymous, and the object-level descriptions never reached
docs/api/openapi.yaml. One of them carries the clause that scopes every
rule on the invitation endpoint to an invited grant, which is the
sentence that stops a reader carrying the step-up and the refusals
across to a guardian grant.
Bind each to a const, reference the const, and correct the level
boundary in the invitation's description while it is being published:
it still carried the blanket denial that stopped being true when the
anamnesis surface opened to a manage delegate.
The wire format does not move. Four named components appear and the four
bodies point at them; the fields, the types and the requiredness are
what they were. A new guard asserts every id this route table declares
reaches the built document, because the failing form looks exactly like
the working one.
The shared-record strip was forced by capturing /api/auth/me and serving
it back with the sharing block filled. That state is now proved across
the payload validator, the fence store and the transition machine at
once, so a single route mock cannot produce it and the two narrow
repairs both stop one step short of the shell.
So the spec stops faking it: its own login jar, a real switch into the
seeded whole-record READ grant, and a place on the list of files that
run in exactly one project, because a second project would drive the
same session row from two browsers. The viewport below md that project
used to cover is measured inside the desktop project instead, where the
shell picks its column layout from the same width-driven rules.
Seven migrations, 0296 through 0302, apply to an empty database in order and
install both triggers. The allocation had been written down as four, and every
later document that quoted it inherited a range that had already moved.
`scripts/verify-v137-adversarial.ts` is the allocation as a runnable artifact
rather than a table somebody keeps in step by hand. It names all seven, refuses
a number allocated twice, asserts each trigger is in the migration that claims
it, and counts every release inventory against a written-down number instead of
against "more than zero" — a list that lost half its entries satisfies a
non-empty check and fails this one. It also refuses to report success when it
ran no checks at all.
It found two things on its first run. Eleven handler entries looked like
duplicates because the key ignored the action, and an `[id]` route carries an
update and a delete as separate contracts; the key includes the action now. The
journey-count leg was then falsified deliberately by expecting nine, which
failed on the count and exited non-zero.
The `push_attempts` principals are declared optional in the schema and NOT NULL
in the database, and that is deliberate rather than drift. The attribution guard
is a BEFORE INSERT trigger, so a write omitting both has them filled from the
legacy mirror before the constraint is checked; requiring them in the schema
would force every established self-delivery writer to name a pair it has no
opinion about, and relaxing the columns would let a partial pair reach the
table, which is what the trigger refuses. `migrate diff` reports the difference
because it cannot see triggers, so the reason is written at the columns and
names the test that proves the accommodation still works.
Measured rather than assumed: applying trunk's chain to its own empty database
reports the same classes of diff — truncated index names, timestamp precision,
foreign keys re-stated. The release adds eight lines to that, seven of them in
classes trunk already carries and one of them the deliberate pair above.
The eight journeys were enumerated and one of them was wired. The other
seven existed as claims in a fixture file, which is the gap the enumeration
was written to close: a browser suite reports what it ran, never what it did
not, and a journey nobody wired reads exactly like a journey that passed.
All eight are wired now, and the list is checked in both directions — an
enumerated journey missing from the wiring fails, not just a wiring entry
naming a journey nobody declared. Before this, a subset was legal, so a
dropped journey and a not-yet-written one looked the same.
A journey names more than one file where more than one file carries it. J1
claims a grant runs through invite, accept, switch, expiry, revoke and a
direct URL; the browser proves the switch, what each level opens and the
direct-URL refusal, and the transitions either side are database facts
proved against real Postgres. Folding that into one file would mean a
browser spec driving expiry through the clock or an integration file
asserting what a page renders, and both are worse tests than the two that
exist.
Three journeys ride Playwright and five ride Vitest, so a green run of this
guard means all eight are claimed and here is which have executed in the
ordinary gate — never that all eight passed. The runner is derived from the
path rather than trusted, so it cannot become a label set to whichever value
made the split read better.
The extractor now reads `it(` as well as `test(`, and the parameterised
`it.each(table)("title")` form alongside the single-call one. It saw neither
before: five of the journeys are proved by Vitest files, every per-file leg
is over a matched set, and an empty match set satisfied them all. The
synthetic spec that proves the parse grew a Vitest half, and it is what
caught the parameterised form being invisible. What the matcher still cannot
see is written into its own doc comment.
The cross-midnight test read `push_attempts` for a synthetic `DEDUP`
channel. Separating the scheduler's bookkeeping from delivery diagnostics
moved the anchor to `notification_events`, and nothing has written that
channel since. The query matched no rows, `findMany` answered the empty
list, and the assertion compared an empty set it had no reason to expect —
a check that could not fail whatever the tick did.
It now reads `notification_events` by record and event type, and asserts
the single row's `dedupKey`. Removing the claim from the tick fails the
dispatch-count leg above it; narrowing the key so it no longer names the
slot fails this leg on the key's own content, which is what makes it a
check rather than a comment.
Four cases in the accountAccess integration test were written against a
contract this release rejected. They asserted an entry of seven fields and a
block of three, both of which gained one; and they asserted that a MANAGE
grant publishes `access: "manage"`.
It does not, by design. `access` is the v1.36.0 name and its union is
`read | write` — a client compiled against that release decodes it as a
closed set. MANAGE therefore publishes as `write` there and as `manage` on
`level`, and the entry schema raises a validation issue unless a manage
entry carries exactly legacy write access and whole scope. The expectations
move; the schema does not.
`recordKind` is required on an entry and on the block. Both are asserted
now, including the block-level value moving from `self` to `shared` across
a switch, so a value stuck on either reads as two cases disagreeing.
Nothing to reconcile in the tree: main's dashboard-hydration fix is already
on this branch as an identical patch, and main's delegated-dose replay guard
predates the branch point. The merge records the ancestry so the release
branch cannot be read as behind trunk.
playwright.config.ts is the one file both lines touched. Main added the second
web server on 3100 that runs the shipped DASHBOARD_SSR_PREFETCH configuration;
this branch added the service-worker project and the per-spec project
exclusions that keep every session-moving spec in exactly one project. The
merged file carries both, and the only difference against main is those 49
added lines.
The floor was described on the one route that cannot reach it and left unsaid
on the two that answer it every time: withdrawing the last Guardian grant and
renouncing it both raise `managed_profile.guardian.required`, and both
published only 'that access had already ended'. Both 409s now name their two
codes, and the annotation on the removal route enumerates all four emitters
rather than three.
A guard closes the class rather than the instance: every route module that
raises the refusal must publish it on its own operation, with the emitters
discovered from the sources.
The freeze guard gains its containment leg — a module becomes delegable by
review, not by discovery, so every file contributing a fenced verb has to be a
member of one of the surface guard's frozen allowlists.
The lifecycle suite took the invitation route's answer as an account-grant row
and reached for `id` and `expiresAt`. It answers with the roster's entry now,
so the id arrives as `grantId` and the expiry is read off the stored column,
which proves it was persisted rather than echoed.
Freezes the authority split the third level ships with. A token exercises a
MANAGE grant, and a Guardian's authority over a managed profile, exactly as a
browser does — proved at the resolver, where neither had a Bearer case before.
A token cannot offer MANAGE and cannot create a managed profile at all, because
both gates resolve through the session cookie; the second had no test until now,
and it runs against a real database with a wildcard token to make the width of
the permission irrelevant.
The claim set is held together by a map naming, per claim, the file and the test
title that proves it, so removing any one of them fails rather than quietly
narrowing what a native client may do.
The fence's client-facing half is frozen beside it: the account payload publishes
the record context on a cookie session and null on a token, and the published
contract says so under the same header names the server matches on.
Runs the legacy whole-record decoder over the entries `resolveAccountAccess`
actually produces, rather than over the fixture objects that describe them: the
existing case proves both ends and never the assembly between them, which is the
shape of test this project keeps rediscovering. Whole READ and WRITE decode,
MANAGE decodes as write and never above the level it confers, a scoped grant is
refused rather than read as the whole record, and a stored scope the server
cannot parse reads as nothing on both ends.
The canonical readers are driven over the same server-built entry, so
`level`, `sections` and `recordKind` are proved to survive the trip to the
capability resolver. The account-payload guard gains the pair for
`recordSession`: a field whose only reader is the transport type is exactly
what that guard exists to catch.
Every route that resolves a record through the sharing fence can answer 403
`sharing.access.denied`, and until now that fact was published on the selector
header and on none of the 120 operations that produce it. All 120 carry it now,
byte for byte, from one constant: the refusal is deliberately indistinguishable
across a missing account, a missing grant, a scope that does not reach the
surface and a read grant on a write, so 120 paraphrases would invite a client to
tell apart what the server refuses to.
Where a delegable route already answered 403 for its own reason — a module
gate, the cycle gate, AI consent — the two share one description rather than one
of them going unpublished, since OpenAPI allows a single response per status.
The guard recomputes the delegable set from the route sources on every run and
classifies per verb, which matters: seventeen modules have a delegable read
beside a write that refuses a switch outright.
The contract already pointed at them: the Guardian roster names
`DELETE /api/managed-profiles/{id}/guardians/{grantId}` as the route its
`grantId` is for, and that route was not in the spec. Creation, deletion,
invitation and removal now are, with the two bodies moved to
`src/lib/validations` so the wire shape has one source.
Three things are stated rather than left to be discovered:
- the two refusals on the invitation that carry no `meta.errorCode`, and the
order a client must resolve in because of them
- the last-Guardian 409 on the removal route, which nothing can elicit through
that route today, published with the reason
- that the sentences on the ordinary invitation describe an invited grant only,
so a client cannot carry its MANAGE rules across to a Guardian grant
- publish the roster's entry from the invitation and `{ grantId, revokedAt }`
from the removal, instead of the account-grant row Prisma returned
- drop the invitation's self refusal: it named a code no request can elicit,
because a Guardian grant's grantor is the profile and never the caller
- name the duplicate grant error rather than catching every grant refusal into
a sentence that would be wrong for any other one
- keep the rate limit and the unknown-identifier 404 free of an error code,
as their siblings elsewhere are, and say why at the call site
- bound profile creation at ten an hour per account, the ceiling its
guardian sibling has carried since it shipped
- distinguish a failed roster read from a slow one, with the way back
- cover the three caller states the roster read has to refuse: an
unaccepted invitation, an expired grant, and MANAGE held elsewhere
- cover what deletion does to a guardian standing inside the record
recordKindLabel chooses between two sentences that mean different things
and nothing could fail it: the journey asserted the data attribute, which
that function never reads, so inverting the arms left the suite green
while a guardian standing inside a managed profile was told it was an
ordinary shared record.
The pending-acceptance note renders unconditionally inside the invite
form, so asserting it after the invitation proved a string existed rather
than anything the journey did. The claim moves onto the pending row,
which the server has to produce. The note keeps its own leg in the unit
suite, where deleting the node makes it red.
- deleting a profile and ending a guardian's access resolve fresh MFA,
and both sit behind a confirmation, so the request that reaches the
server is minutes old and routinely outside the five-minute window
- an account with no second factor is told where to set one up instead
of being asked to confirm one it does not have
- the arm is scoped to those two acts: a 401 on accept, revoke or
renounce is a lapsed session and must not read as a factor prompt
- a refused field gets its own announced node in content contrast; the
standing hint stays muted beside it, rather than being overwritten
- the timezone refusal is the only explanation for a submit button that
will not press, so it leaves the picker's muted hint slot