Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions skills/fomo-kernel/references/ux-receipt.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ python3 tools/ux_receipt.py start \

Universal fallbacks are written into both declarations automatically. The CLI rejects a `plain_text` adapter claiming optional controls, and a `validated_widget` adapter missing either native controls or a widget.

On `first_review` and `weekly_review` — the two routes that structurally owe the cash-anchor check below — also transcribe the executed plan's `input.cash_anchor.status` (`data-contract.md`): `anchored`, `partial`, or `absent` change nothing, but `not_applicable` — for example on a light-tier week — tells `verify` this trace owes zero `cash_anchor_checked` events instead of one:

```bash
python3 tools/ux_receipt.py start \
--session-id <session_id> --client <client> --route weekly_review \
--adapter plain_text --cash-anchor-status not_applicable
```

Omitting `--cash-anchor-status` keeps `verify`'s original unconditional reading — exactly one `cash_anchor_checked` event is still required — so every receipt written before this flag existed still verifies unchanged. What declaring `not_applicable` changes is explained below, after `--cash-outcome`.

## Events

```bash
Expand Down Expand Up @@ -54,13 +64,13 @@ python3 tools/ux_receipt.py event --session-id <id> --event card_presented --sta

`native_options` and `plain_text` share the same `surface_digest` and write the same canonical answer. The trace rejects extra question-content fields.

`--cash-outcome` takes exactly one of `found_in_source` (the statement carried a balance row, so nothing was asked), `provided` (the user was asked at the card beat and gave one), or `declined` (the user was asked and did not). Every value states what the *user's* data or answer decided; there is no value for "the agent decided not to ask". The retired `skipped` was exactly that, and #357's fifth recurrence recorded it correctly and in order while the user was never offered the question — the gate passed and the experience was identical to forgetting. Now a run that never asked can record nothing, and `verify` refuses a trace with no `cash_anchor_checked` on a route that owes one, so "nobody was asked" and "they declined" are different traces.
`--cash-outcome` takes exactly one of `found_in_source` (the statement carried a balance row, so nothing was asked), `provided` (the user was asked at the card beat and gave one), or `declined` (the user was asked and did not). Every value states what the *user's* data or answer decided; there is no value for "the agent decided not to ask". The retired `skipped` was exactly that, and #357's fifth recurrence recorded it correctly and in order while the user was never offered the question — the gate passed and the experience was identical to forgetting. Now a run that never asked can record nothing, and `verify` refuses a trace with no `cash_anchor_checked` on a review whose declared plan says the check was owed, so "nobody was asked" and "they declined" are different traces.

Position follows the outcome, and `verify` enforces it both ways. `found_in_source` is read before `prepare` runs, so it must precede the first question or card — retrospective evidence rather than a self-report, the same anti-backfill rule as the weekly opener. `provided` and `declined` record a question asked in the same message as the preview card (`data-contract.md`), so they must come *after* the first `card_presented`; a `declined` recorded earlier is refused, because at that point there was no card the question could have been attached to.

When the user provides an anchor, `review.py add-cash` recomputes the review and returns a new session id. **Keep the original session id for the whole trace.** A receipt records one conversation with a user, not one engine session — the same reason a refresh trace is keyed by `refresh_id` and a `consider` trace by `evaluation_id`.

`snapshot_review` states cash inline in its own envelope and `test_drive` persists no anchor, so neither carries this requirement; `input.cash_anchor.status` says `not_applicable` on both, and on a light-tier week.
`snapshot_review` states cash inline in its own envelope and `test_drive` persists no anchor, so neither carries this requirement at all — `ROUTE_CONTRACTS` says so structurally, independent of anything a trace declares. `first_review` and `weekly_review` do carry it structurally, but a *specific* review can still have no cash-anchor opportunity: a light-tier week's plan carries `input.cash_anchor.status == "not_applicable"` (reason `light_tier`) the same positive way a snapshot's or a test drive's does (`data-contract.md`). Transcribe that status with `--cash-anchor-status not_applicable` at `start` (above, #677), and `verify` requires **zero** `cash_anchor_checked` events instead of one for that trace — recording one anyway, of any outcome, fails as a named contradiction: the plan this trace declares it walked said no check was owed, so an event claiming one happened cannot also be true. A `declined` written to make an honest gap verify is exactly the fabrication this tool exists to catch, not a way around it. Omit the flag and `verify` keeps the original unconditional reading, so a receipt written before it existed still verifies exactly as it did.

`answers_received` is a content-free latency marker. It makes the answered-to-card wait measurable from the trace as `card_presented(stage=preview).ts - answers_received.ts`.

Expand Down Expand Up @@ -182,7 +192,7 @@ All four route axes must be `pass` before `verify --require-owner-verdict` accep
python3 tools/ux_receipt.py verify --session-id <id>
```

`verify` fails when a stage's card was not presented after its artifact, when the final card precedes the preview card, when a declared widget degraded to Markdown with no recorded failure, or when a weekly opening memory did not precede the first card. Which of those a trace owes is decided by its declared route — cards, cash anchor, opening memory, change surface, and verdict axes are declared once per route inside the tool, so a route either carries an obligation or it does not, and none of them can be skipped by wording. It does not re-check answered questions or the commitment — the engine owns those.
`verify` fails when a stage's card was not presented after its artifact, when the final card precedes the preview card, when a declared widget degraded to Markdown with no recorded failure, or when a weekly opening memory did not precede the first card. Which of those a trace owes is decided by its declared route — cards, cash anchor, opening memory, change surface, and verdict axes are declared once per route inside the tool, so a route either carries an obligation or it does not, and none of them can be skipped by wording. The cash anchor is narrowed once more, per trace rather than per route: a `first_review`/`weekly_review` trace that declared `--cash-anchor-status not_applicable` at `start` owes zero `cash_anchor_checked` events instead of one, and owes a failure if it recorded one anyway. It does not re-check answered questions or the commitment — the engine owns those.

Timing plausibility is a separate signal. Verification stays compatible with legacy receipts and exits successfully with a `WARN` and `timing_integrity.status=suspect` when stamped rows reverse or an entire owner-verdict trace was recorded in a sub-three-second burst. A suspect result sets `owner_live_eligible=false` and cannot be cited as owner-live UX ground truth; audit contemporaneous evidence or re-run the walkthrough. Legacy receipts without `ts` pass ordinary verification but are `not_assessed` rather than fresh evidence.

Expand Down
61 changes: 58 additions & 3 deletions skills/fomo-kernel/tools/ux_receipt.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ def _default_state_root() -> str:
# all. A run where nobody was asked can now record nothing, which `verify`
# refuses, so it is distinguishable from one where they declined.
CASH_OUTCOMES = ("found_in_source", "provided", "declined")
# What the executed Review Plan said about this review's cash-anchor
# obligation (`input.cash_anchor.status`, `references/data-contract.md`),
# declared once at `start` the same way `route` itself is a transcribed plan
# fact rather than something this tool re-derives (#677). `anchored`,
# `partial`, and `absent` all mean an anchor was in play; `not_applicable` —
# a light-tier week, a declared snapshot, or a test drive — means none was,
# so `verify` reads it to decide whether `cash_anchor_checked` is owed on
# *this* trace at all, rather than only how it is positioned once written.
CASH_ANCHOR_STATUSES = ("anchored", "partial", "absent", "not_applicable")
# The two moments a card-free lane becomes visible to the user (#523). A book
# refresh (`flows/book-refresh.md`) narrates the engine's own difference and
# then reports what it recorded; those are the surfaces this trace can prove
Expand Down Expand Up @@ -194,6 +203,15 @@ def _default_state_root() -> str:
# `declined` after the first card. A declared positions snapshot
# states `cash` inline in its own envelope, and `test_drive`
# persists no accounting anchor at all, so neither owes one.
# On a route where this is True, whether *this instance* owes
# the event is narrowed further by the trace's own declared
# `cash_anchor_status` (#677): `not_applicable` — the plan's
# own classification, for example a light-tier week — means
# zero events is the honest trace, and any event recorded
# anyway contradicts the plan the same trace declares it
# walked. Omitting the declaration keeps the original
# unconditional reading (exactly one event), so a receipt
# written before this field existed still verifies unchanged.
# opener memory kinds, exactly one of which must precede the first
# surface. `()` for a route that carries no opening memory.
# change True — at least one visible change surface: the narrated diff
Expand Down Expand Up @@ -432,6 +450,12 @@ def start_receipt(args: argparse.Namespace) -> None:
"question_modes": question_modes,
"card_modes": card_modes,
}
# Optional and omitted by default (#677): a caller that does not transcribe
# the plan's cash-anchor status gets the original unconditional reading in
# `verify_rows`, so every trace written before this field existed — and
# every route this field does not matter on — is unaffected.
if args.cash_anchor_status is not None:
row["cash_anchor_status"] = args.cash_anchor_status
_append(path, row)


Expand Down Expand Up @@ -1076,6 +1100,13 @@ def verify_rows(rows: list[dict], require_owner_verdict: bool = False,
errors.append(f"unsupported trace version: {declaration.get('version')!r}")
if route not in ROUTES:
errors.append(f"unsupported route: {route!r}")
# #677: validated unconditionally, like every other declared enum, rather
# than only where the route currently reads it — a hand-built or
# hand-edited trace can carry the field on any route, and a garbage value
# should fail the same way an unsupported route or event kind does.
declared_cash_status = declaration.get("cash_anchor_status")
if declared_cash_status is not None and declared_cash_status not in CASH_ANCHOR_STATUSES:
errors.append(f"unsupported cash anchor status {declared_cash_status!r}")
if any(row.get("session_id") != session_id for row in rows):
errors.append("all events must use the declared session_id")
for index, row in enumerate(rows, 1):
Expand Down Expand Up @@ -1367,11 +1398,27 @@ def verify_rows(rows: list[dict], require_owner_verdict: bool = False,
# and a `declined` recorded before any card is refused, because at that
# moment there was no card the question could have been attached to.
#
# A light-tier session never calls this tool at all, and its plan says so
# (`input.cash_anchor.status == "not_applicable"`, reason `light_tier`).
# #677: whether *this instance* owes the event at all is read from the
# trace's own declared `cash_anchor_status`, not assumed from the route.
# The premise this check used to rest on — "a light-tier session never
# calls this tool at all" — held only while receipts were optional; gate 3
# (`docs/qa-runbook.md`) now wants one for every route run, and a
# light-tier weekly review's plan legitimately carries
# `input.cash_anchor.status == "not_applicable"` (reason `light_tier`).
# Zero cash-anchor events is the honest trace for that plan, and a
# fabricated `declined` recording a question that was never asked is
# exactly the dishonesty this tool exists to prevent — so it fails here,
# named as a contradiction rather than accepted as evidence.
if contract["cash_anchor"]:
checks = _positions(rows, "cash_anchor_checked")
if len(checks) != 1:
if declared_cash_status == "not_applicable":
if checks:
errors.append(
f"{route} declared input.cash_anchor.status=not_applicable, so "
f"row {checks[0] + 1}'s cash_anchor_checked event contradicts the "
"plan this trace says it walked — a check the plan says was not "
"owed cannot also have happened")
elif len(checks) != 1:
errors.append(f"{route} must record exactly one cash_anchor_checked event")
else:
index = checks[0]
Expand Down Expand Up @@ -1528,6 +1575,14 @@ def add_common(sub: argparse.ArgumentParser) -> None:
help="extra capability beyond the universal plain_text fallback")
start.add_argument("--card-mode", action="append", choices=CARD_MODES, default=[],
help="extra capability beyond the universal markdown_inline fallback")
start.add_argument(
"--cash-anchor-status", choices=CASH_ANCHOR_STATUSES, default=None,
help="input.cash_anchor.status from the executed plan, on a route that "
"structurally owes the #357 check (first_review/weekly_review); "
"not_applicable (for example a light-tier week) means verify "
"requires zero cash_anchor_checked events instead of one. Omit "
"to keep the original unconditional reading of exactly one.",
)
start.set_defaults(handler=start_receipt)

event = subparsers.add_parser("event", help="append a presentation fact after the user-visible action")
Expand Down
Loading
Loading