Skip to content

fix(maintenance): dedupe + label ESCALATION mails from reaper and jsonl-export - #4

Open
austinborn wants to merge 1 commit into
mainfrom
maintenance-escalation-dedupe
Open

fix(maintenance): dedupe + label ESCALATION mails from reaper and jsonl-export#4
austinborn wants to merge 1 commit into
mainfrom
maintenance-escalation-dedupe

Conversation

@austinborn

Copy link
Copy Markdown

Summary

  • Adds a shared bash helper examples/gastown/packs/maintenance/assets/scripts/escalation.sh that wraps gc mail send with per-(subject, body) sha256 dedupe + a configurable cooldown (default 6h, GC_ESCALATION_COOLDOWN_SECONDS env override).
  • Sources the helper from reaper.sh (anomaly escalation) and jsonl-export.sh (spike alert + push-failure escalation), so both stop flooding the mayor inbox on a persistent condition.
  • Best-effort labels the resulting ESCALATION beads with wisp_type:escalation by parsing the new bead id from gc mail send's "Sent message to " stdout line, so wisp-compact's 7d retention class actually applies. Untagged escalation mails were falling into the 24h default bucket.
  • Adds an occurrence-counter footer ([Suppressed N time(s) since <ts>; cooldown Xs.]) to released-after-suppression mails so the operator sees cadence, not just the latest sample.
  • Adds clear_escalation_state(state_file, [subject]) so callers can wipe dedupe entries when the underlying condition resolves. Wired into reaper.sh (no-anomaly tick) and jsonl-export.sh's record_archive_push_success.

The behavior change is conservative: when a single anomaly condition is reported on every tick (the failure mode this fixes), suppress everything but the first send per cooldown window. When the condition's body text changes (different counts, different anomaly list), the sha256 changes and the new variant escalates fresh.

Why

The mol-dog-reaper order's anomaly escalation on reaper.sh line 555 sends a fresh mail to the mayor on every 30-minute tick where $ANOMALIES is non-empty. With no per-message dedupe, an unchanging condition — e.g. the hq Dolt schema gap where the dependencies table lacks the split target columns — generates dozens of identical ESCALATION mails over a few days. The most recent observed window produced 20+ duplicate mails over 44 hours; an earlier 158-mail JSONL flood from the same gap class motivated the wisp-compact retention work that landed for jsonl-export.sh, but that prior fix did not extend to reaper.sh, and an even earlier labelling attempt for jsonl-export.sh (branch label-jsonl-escalation-wisp-type, commit 61678a5) was never merged.

This change closes both gaps in one PR by factoring the helper and applying it to both scripts. It supersedes the unmerged label-jsonl-escalation-wisp-type branch — the helper subsumes that branch's labelling behavior and adds dedupe on top. Reviewer can close that PR in favor of this one.

Test plan

  • go test ./examples/gastown/... -run TestReaperEscalation -v — 4 new reaper-side tests covering: dedupe within cooldown, state-clearing on condition-resolved tick, release-after-suppression footer, and bd-label-add on a successful send.
  • go test ./examples/gastown/... -run TestJsonlSpikeEscalationSuppressesRepeats -v — dedupe end-to-end through send_spike_alert in jsonl-export.sh.
  • go test ./examples/gastown/... (full package) — confirms no regressions in the existing maintenance-script tests (passed locally in 113s).
  • Manual: tail the operator's mayor inbox over one reaper tick window after merge + gc import update. The first tick with anomalies should produce one mail; subsequent ticks within 6h should be silent; the first tick after the cooldown should produce a mail with the "Suppressed N time(s)" footer.

Out of scope

  • The underlying hq schema gap that drives the anomaly text on every tick. The dedupe layer here removes the FLOOD symptom regardless; the migration that would clear the anomaly condition itself is a separate operator decision.
  • Bundle refresh and ephemeral-bead housekeeping. Those happen after this PR merges and the operator runs gc import update against the city's maintenance pack.

Generated by the operator's software factory.
• City: `factory-main` · Agent: `local-core__builder-fm-9nrnx4`
• On behalf of: @austinborn

…jsonl-export.sh

The mol-dog-reaper order (reaper.sh) sends an ESCALATION mail to the
mayor on every 30-minute tick that finds non-empty $ANOMALIES, with no
per-message-key dedupe, no cooldown, and no wisp_type label. Persistent
conditions — e.g. an hq schema gap where the dependencies table lacks
the split target columns — therefore flood the mayor inbox. The most
recent incident produced 20+ identical "ESCALATION: Reaper anomalies
detected [MEDIUM]" mails over a 44-hour window; the earlier 158-mail
JSONL-spike flood that motivated the prior wisp-compact retention work
was the same class of problem in a sibling code path that the prior
fix did not cover.

This commit factors a shared bash helper, escalation.sh, sourced by
both reaper.sh and jsonl-export.sh. The helper:

- Computes sha256(subject + "\n" + body) as a dedupe key and stores
  the last-sent timestamp + a suppressed-while-in-cooldown counter
  in a caller-provided JSON state file (atomic mv writes).
- Suppresses repeat sends within a configurable cooldown window
  (default 6 hours, GC_ESCALATION_COOLDOWN_SECONDS env override).
- On the first send after a suppression streak, appends a one-line
  "[Suppressed N time(s) since <last_sent_at>; cooldown <s>s.]"
  footer so the operator sees the cadence rather than just the
  latest report.
- Best-effort labels the resulting bead with wisp_type:escalation by
  parsing the new bead id from `gc mail send`'s "Sent message <id>
  to <to>" stdout line and calling `bd label add`. wisp-compact
  already honors wisp_type:escalation as the 7d retention class;
  untagged escalation mails were falling into the 24h default bucket.
- Exposes clear_escalation_state(state_file, [subject]) so callers
  can wipe dedupe entries when the underlying condition resolves
  and the next firing should escalate fresh.

Wiring:

- reaper.sh sources escalation.sh, gets a new
  $PACK_STATE_DIR/reaper-state.json state file (reaper had no state
  file before), and routes its anomaly send through
  send_escalation_mail. When $ANOMALIES is empty on a tick,
  clear_escalation_state wipes the dedupe entry so a future genuine
  anomaly is not suppressed by the resolved one's cooldown.

- jsonl-export.sh's send_spike_alert and the push-failure escalation
  now both call send_escalation_mail. record_archive_push_success
  additionally calls clear_escalation_state for the push-failure
  subject so a re-introduced push failure escalates fresh. This
  subsumes the unmerged label-jsonl-escalation-wisp-type branch
  (commit 61678a5), which added labelling but no dedupe; that
  branch can be closed in favor of this PR.

Tests (examples/gastown/maintenance_scripts_test.go):

- TestReaperEscalationSuppressesRepeatAnomalyWithinCooldown — two
  reaper ticks with identical anomalies result in exactly one mail
  and a suppressed_count=1 dedupe entry in the state file.
- TestReaperEscalationClearsStateWhenAnomaliesResolve — anomaly
  tick, then no-anomaly tick (which must clear dedupe state), then
  anomaly tick again, must produce 2 fresh mails total.
- TestReaperEscalationLabelsBeadAfterSend — when the gc stub mirrors
  real `gc mail send`'s "Sent message <id> to <to>" stdout, the
  follow-up `bd label add <id> wisp_type:escalation` lands in the
  bd log.
- TestReaperEscalationReleaseFooterReportsSuppressedCount — backdate
  the state file's last_sent_at and bump suppressed_count, then run
  again; the released mail's body must carry the
  "Suppressed N time(s) since <ts>" footer.
- TestJsonlSpikeEscalationSuppressesRepeats — two back-to-back
  jsonl-export runs with a 400%-delta spike emit exactly one
  ESCALATION mail.

Out of scope:

- The underlying hq schema gap that drives the anomaly text on every
  tick. The dedupe layer here removes the FLOOD symptom regardless;
  the migration that would clear the anomaly condition itself is a
  separate operator decision.
- Bundle refresh and wisp-bead housekeeping. Those happen after this
  PR merges and the operator runs `gc import update` against the
  city's maintenance pack.

Generated by the operator's software factory.
City: factory-main · Agent: local-core__builder-fm-9nrnx4
On behalf of: @austinborn
Co-Authored-By: <operator-factory-bot> <factory-bot@actual-software.invalid>
austinborn pushed a commit that referenced this pull request May 26, 2026
…gastownhall#2082) (gastownhall#2559)

Thanks to @mike-matchpoint for the clear repro and the structured
options menu in gastownhall#2082 — this PR implements the smallest set of
fail-closed mitigations (options #2 + #3 from the issue body) that stop
the silent strand at the polecat boundary.

## Summary

The polecat formula's `workspace-setup` step creates a per-bead branch
`polecat/<bead-id>` and the refinery's standard scan only discovers
those. Providers that skip `workspace-setup` (the reported codex case)
commit on whatever branch happens to be checked out in the agent's home
worktree, and the refinery silently never finds the work — beads end up
"assigned to refinery, no merge target", requiring manual recovery.

Two mitigations, both fail-closed:

### 1. Branch-shape gate in `mol-polecat-work.toml` (`submit-and-exit`)

A new **step 1** runs before the push and before the refinery reassign:

- Reads `git branch --show-current`.
- Refuses to proceed if the current branch is not `polecat/{{issue}}` —
prints recovery instructions, signals `gc runtime drain-ack`, and exits
1.
- Also reconciles `metadata.branch` so the refinery's metadata view
matches what is about to be pushed (in case `workspace-setup` recorded a
divergent value).

This stops the strand at the polecat boundary instead of after the bead
has already advanced to "assigned to refinery". Existing step numbers
shift by one (3→4 cleanup, 4→5 metadata, 5→6 reassign, 6→7 signal, 7→8
reconciler+exit); the existing
`TestPolecatFormulaSignalsRefineryAfterReassign` assertions are updated
to match.

### 2. `CRITICAL: Branch Convention` section in `prompt.template.md`

The original prompt deferred all branch detail to the formula
description. A provider that skips reading the formula now still sees
the `polecat/<bead-id>` rule **inline** in the prompt, with a worked
example table.

## Tests

- **New** `TestPolecatFormulaSubmitHasBranchShapeGate` — asserts the
gate body appears in order before the push and before the refinery
reassign, AND that the `metadata.branch` reconciliation is present.
- **New** `TestPolecatPromptInlinesBranchConvention` — asserts the
CRITICAL section names the convention and references gastownhall#2082.
- **Updated** `TestPolecatFormulaSignalsRefineryAfterReassign` — step
renumber.

## Out of scope (deferred from the issue body)

- **Option #1** (move per-bead worktree+branch creation into a pre-claim
hook) — the more robust structural fix, but touches the
supervisor/dispatch layer and warrants a maintainer design call. Belongs
in a separate PR.
- **Option #4** (codex-specific memory instructions) — provider-side and
out of the formula+prompt surface this PR addresses.

This change covers the prompt + formula layer where polecats already
operate; the supervisor-side hook is its own PR.

## Files

`examples/gastown/packs/gastown/formulas/mol-polecat-work.toml`,
`examples/gastown/packs/gastown/agents/polecat/prompt.template.md`,
`examples/gastown/gastown_test.go` (+141 / -11)

Closes gastownhall#2082

---------

Co-authored-by: sjarmak <sjarmak@users.noreply.github.com>
austinborn pushed a commit that referenced this pull request Jul 24, 2026
…reates (P0 #4 S2) (gastownhall#4150)

## What

S2 of **audit P0 #4 — `Idempotency-Key` on mutating create endpoints**:
wires the five
batch-A creates through the `withIdempotency` helper from S1 (gastownhall#4147) so
a timed-out
client retry cannot mint a duplicate:

- `create-agent` (POST /agents)
- `create-provider` (POST /providers)
- `create-rig` (POST /rigs)
- `create-convoy` (POST /convoys)
- `add-pack` (POST /packs)

**Depends on gastownhall#4147 (S1).** Base=main per the pipeline convention — the
diff shows both
slices until S1 merges, then auto-cleans to S2 only. Left unlabeled
until gastownhall#4147 merges.

## How each endpoint is wired

Each input struct gains the optional `Idempotency-Key` header field, and
**all fallible
work moves into the `create()` closure** — validation, the pack SSRF
fence, the
config/store write, the agent visibility wait, the convoy link loop +
rollback — so the
helper's deferred release covers every error and panic path (no leaked
reservations).

Replay caches the response **body value** only:

| endpoint | cached `T` | why |
|---|---|---|
| create-agent / provider / rig | `string` (the name) | output envelopes
have anonymous inline bodies `{status, name}` — naming them would rename
generated OpenAPI schema components; the body is rebuilt from the name |
| create-convoy | `beads.Bead` | `IndexOutput` envelope; `X-GC-Index`
recomputed fresh on replay (the create-bead pattern) |
| add-pack | `importsvc.AddResult` | the body echoes all four fields |

Cache paths are static (`/v0/agents`, …) — safe because the idempotency
cache lives on
the **per-city `Server`** (`getCityServer` caches one Server per city),
the same
property the beads/mail slices already rely on.

## Deliberate semantics (unchanged from an unkeyed retry)

A create that fails **after** a durable write (agent visibility timeout
503/504, pack
lockfile, convoy rollback failure) releases the reservation, so a
same-key retry re-runs
the create and surfaces the conflict (409 already-exists) rather than
replaying success.
Idempotency replays only completed successes; it cannot invent one.

## Contract changes

- 5 ops gain the optional `Idempotency-Key` header parameter (spec + Go
client +
  dashboard TS client regenerated and committed).
- `create-convoy` now declares 409 (`idempotency-in-flight`); the other
four already
  declared 409.
- Guard lists (`TestCreateEndpointsAreTriagedForIdempotency`): the 5
opids moved
  `pending` → `require`.

## Tests / gates

- New `idempotency_endpoints_test.go`: per-endpoint
replay-without-re-running-create
  (config/store/import-call counting) + a wire-level 422 mismatch test.
- Helper test now pins that the **key participates in the cache scope**
(mutation-verified: dropping `+ key` from `scopedKey` fails two tests).
- `gofmt`/`go vet` clean; full `internal/api` suite green;
`TestOpenAPISpecInSync`,
  `TestGeneratedClientInSync`, `make dashboard-check` all pass.
- Red-teamed pre-commit (5 lenses + adversarial verification, 13
agents): no code
defects confirmed; the mutation-hardening test and explicit staging of
the new test
  file came out of that review.

## Follow-ups

S3 wires reply-mail, register-extmsg-adapter, emit-event, and supervisor
create-city
(needs the `withIdempotency` receiver generalized from `*Server` to the
cache, since
`SupervisorMux` has no Server), and moves `ensure-extmsg-group` → exempt
(identity-idempotent by design). S4 (deferred): the session creates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
austinborn pushed a commit that referenced this pull request Jul 24, 2026
gastownhall#4155)

## What

`isBdTransientWriteError` gets a deliberately conservative sqlite arm:
bd write errors carrying an explicit `SQLITE_BUSY` / `SQLITE_LOCKED`
result-code marker (e.g. `database is locked (5) (SQLITE_BUSY)`) are now
classified transient and flow through the existing bounded-backoff write
retry loop, exactly like Dolt serialization failures. Only the
unambiguous code markers match — bd's sqlite driver (modernc.org/sqlite)
always appends them, so no real coverage is lost, while bare "database
is locked" phrasings stay excluded on purpose: Dolt's embedded mode
emits `database is locked by another dolt process` for a persistent
lock-file condition that a bounded retry cannot clear and must keep
failing fast.

Two hardening commits ride along in `cmd/gc` (see Why): a nil-`stderr`
guard in `computeWorkSet`, and `t.TempDir()` instead of literal `/tmp`
as the city dir in the `computeWorkSet` tests.

## Why

The sqlite concurrency audit of the infra store (defect #4, CONFIRMED)
found the write classifier matched only Dolt/MySQL needles, so a
`SQLITE_BUSY` from a sqlite-backed bd was classified permanent and never
retried: any write that lost a lock race failed on first contention. The
audit's empirical hammer proved the trigger window is real and wide —
because bd reads also take the single write lock, one long writer (a
26k-issue `bd import` holding the lock ~118s) failed 100% of concurrent
operations with `SQLITE_BUSY` rc=1, each surfaced to gc as a permanent
error even though a retry would have succeeded once the lock cleared. A
BUSY write is the sqlite analog of a Dolt serialization failure: it lost
the race without applying, so it is safe to retry.

The red-team pass on the fix blocked the first push: the pre-push suite
failed in the `computeWorkSet` tests with a nil-pointer panic inside
`fmt.Fprintf`. Tracing it produced the two-part revision in this branch
instead of a workaround:

- `computeWorkSet` took `stderr io.Writer` and wrote probe-env
diagnostics to it, but several callers (all reconciler unit tests, and
any future fire-and-forget caller) pass nil — the probe-env error branch
then panics instead of degrading to "skip this agent". The guard
defaults nil to `io.Discard` at the function boundary.
- The tests passed literal `/tmp` as `cityDir`, so they read whatever
bead-store state the machine had at `/tmp/.beads` / `/tmp/.gc`. Hostile
residue there (an authoritative-but-unresolvable scope config left by an
unrelated process) made `controllerQueryRuntimeEnv("/tmp")` error for
every agent, which is what reached the panic. Base-vs-branch was
established explicitly: with the residue planted, these tests fail
identically at the merge base — the hazard is latent at base and
independent of the classifier change — but it deterministically blocked
this push, so both halves are fixed here rather than papered over. Each
test now uses `t.TempDir()`.

## Verification

- `TestIsBdTransientWriteError` (new) pins the full needle set: sqlite
BUSY/LOCKED markers retried; bare `database is locked` and Dolt's
embedded lock-file phrasing NOT retried; sqlite constraint/syntax errors
NOT retried; all pre-existing Dolt/MySQL needles unchanged.
- `TestBdStoreDepAddRetriesSqliteBusyError` (new) proves end-to-end that
a write failing once with `SQLITE_BUSY` is retried and succeeds on the
second attempt.
- New `computeWorkSet` regression test builds a real erroring probe-env
fixture (authoritative scope config + postgres metadata with no
resolvable password) and proves a nil-`stderr` caller skips the agent
instead of panicking.
- Red-team results: the nil-writer panic and the `/tmp` residue
sensitivity reproduce identically at the merge base (pre-existing, now
fixed); the full `TestComputeWorkSet` set passes with hostile
`/tmp/.beads` residue deliberately planted; the conservative needle set
was checked against Dolt embedded-mode lock errors to confirm no
fail-fast regression.
- Pre-push suite (`scripts/test-local-parallel fast`, 8 jobs: unit-core
+ 6 cmd/gc shards + darwin compile) passed in full on push.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
austinborn pushed a commit that referenced this pull request Jul 24, 2026
… typed 400 on invalid (P1 #4 S1) (gastownhall#4157)

## What

S1 of **audit P1 #4 — stable keyset cursors + one pagination
vocabulary**: replaces the
base64-offset pagination cursor on `GET /v0/beads` with a **versioned
keyset token** and
makes invalid cursors a **typed 400** instead of a silent page-1
restart.

**Why**: offset cursors skip or duplicate rows whenever a concurrent
write shifts the
result set — a guarantee on a live work ledger — and any garbage cursor
silently
restarted the walk (duplicating rows). This is the audit's headline
pagination
correctness bug.

## The contract

- `next_cursor` is now `v1:` + base64url(JSON) carrying the
**(created_at, id) boundary**
of the last row served. Opaque; clients change nothing (the envelope is
unchanged).
- Invalid cursor (garbage, a **legacy offset token**, wrong kind) →
**400 problem+json
`urn:gascity:error:invalid-cursor`** (new catalog code; route declares
400). Blast
radius ≈ 0: the dashboard never sends cursors; the only in-repo pager
(`gc events`,
  a different endpoint anyway) holds one in-memory for a 30s loop.
- **Accept-what-you-mint**: a zero-CreatedAt boundary (degraded rows
with NULL/unparseable
`created_at` sort to the DESC tail) decodes fine — only the ID is
required. Without
this, a walk wedges in a 400 loop on a token the server itself issued
(red-team major,
  pinned by an e2e walk over degraded rows).
- Walk semantics: `Total` keeps full-set meaning and stays constant
across a walk; a
**degraded (partial) page always carries a resume cursor** — the client
gets a boundary
instead of a silently terminated walk, and following it ends cleanly on
an empty page.

## Store layer

`beads.ListQuery` grows `SeekAfter *SeekBoundary` — an exclusive
compound boundary
interpreted against the explicit `Sort`, applied in `Matches` (id
tie-break mirrors
`sortBeadsForQuery` exactly). **Boundary-before-limit on every backend**
whose native
query layer can't express the compound predicate (applying a native
limit first silently
drops page rows):

| backend | mechanism |
|---|---|
| MemStore / CachingStore | Matches-before-limit by construction |
| BdStore (both tiers) | `bdListRequiresClientLimit` +
`canApplyWispsServerLimit` force unbounded fetch + Go-side filter/limit
|
| exec.Store | script-side `--limit` withheld when seeking |
| doltlite (`gascity_native_beads`) | SQL bounded-top-N disqualified,
per-table SQL LIMIT off, exact Go refilter before the limit cut; Count
reports seek unsupported |

Known trade-off: seeked doltlite pages are O(matching history) until the
SQL boundary
push-down lands (**ga-ylo6wr**; the whole-second sortKey truncation
drift makes that a
careful change).

Handler: the bounded `all=true` path pushes the boundary into each store
with
`Limit=limit+1` (the extra row is the has-more signal); the full-scan
path keeps its
un-seeked fetch and suffix-slices after the boundary.

## Tests

Token round-trip + rejection matrix; seek predicate both directions
(ties, sub-second
edges); **mid-walk no-skip/no-dup at three layers** (MemStore,
CachingStore, API) with
concurrent inserts between pages; tied-created_at walk (whole-second
ties are the
bd/doltlite norm); zero-CreatedAt tail walk; store-gate unit tests (bd,
wisps, exec,
tagged doltlite). The two pre-existing bounded tests updated to the new
contract
(limit+1 expectation; cursor-chain walks replacing offset jumps) — their
original
intents (O(limit) push-down, failed-rig Total exclusion) still guarded.

Red-teamed pre-commit (5 lenses + adversarial verification, 18 agents,
12/13 findings
upheld): the zero-CreatedAt wedge, two residual limit-push holes (bd
wisps, exec), the
degraded-walk termination, and the tie/coverage tests all came out of
that review and
are fixed here.

## Scope

mail/convoys/sessions/events still speak offset cursors — they migrate
in S2/S3 of this
track (convoys also needs a deterministic order first; events unifies on
seq DESC). S4
adds the spec-walking dialect guard + limit schema visibility.
Owner-signed decisions:
hard-400 old cursors, full S1–S4 program, grandfather legacy dialects.

Gates: gofmt/vet clean; full `internal/beads` (default + native tag),
`internal/api`,
`exec`, genclient sync, dashboard-check all green. Tracking:
`ga-q1rees`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
austinborn pushed a commit that referenced this pull request Jul 24, 2026
…dapter, and city creates (P0 #4 S3) (gastownhall#4152)

## What

S3 of **audit P0 #4 — `Idempotency-Key` on mutating create endpoints**:
wires the final
four creates (per the owner-signed triage) and generalizes the helper
for
supervisor-scope use:

- `reply-mail` (POST /mail/{id}/reply)
- `emit-event` (POST /events) — owner decision: **wire** (append-only
log, retry double-emits)
- `register-extmsg-adapter` (POST /extmsg/adapters)
- `post-v0-city` (POST /v0/city, supervisor scope, 202)
- `ensure-extmsg-group` → moved to the **exempt** list (owner decision:
identity-idempotent
by design; the accepted retry double-emit of `ExtMsgGroupCreated` is
documented in the guard)

**Depends on gastownhall#4150 (S2) → gastownhall#4147 (S1).** Base=main; the diff auto-cleans
as parents merge.
Left unlabeled until the parents merge. After this slice, only the
deferred **S4 session
creates** remain in `pendingIdempotency`.

## Key changes

- **Helper generalized**: `withIdempotency`'s first parameter changes
`*Server` →
`*idempotencyCache`. POST /v0/city is supervisor-scope — no per-city
`Server` exists for
a city being created — so `SupervisorMux` now owns its own cache; the
seven prior call
  sites pass `s.idem` (mechanical).
- **reply-mail** caches the `mail.Message` with the message ID folded
into the cache scope
— **PathEscaped**, so a `%2F`-crafted ID cannot forge the `/reply:`
boundary and alias
  another (id, key) pair (found by the red-team, pinned by
`TestMailReplySameKeyDifferentMessagesIndependent`). The provider lookup
moves inside
the closure so a replay still succeeds after the original message is
deleted
  (`TestMailReplyReplayAfterOriginalDeleted`).
- **emit-event** caches the status constant — the value is suppressing
the duplicate
append (and the double-count in projections like the Runs view built
over events.jsonl).
- **register-extmsg-adapter** caches the resolved adapter name;
`Register` is an upsert,
  so the win is suppressing the duplicate `ExtMsgAdapterAdded` event.
- **post-v0-city** caches the full accepted body so a replay returns the
**original
`request_id`** (the correlation handle on /v0/events/stream) and the
original pre-create
event cursor — recomputing either would break result-event correlation.
The three 409
guards (already-initialized, init-in-progress, idempotency-in-flight)
coexist.

## Contract

- 4 ops gain the optional `Idempotency-Key` header + declare 409.
- **POST /v0/city error set completed**: it previously had no enumerated
errors (Huma
catch-all `default`); enumerating any error drops that default, so it
now declares
everything it actually emits — 401/403 (write-auth/CSRF middleware),
409, 501 (the
controller-embedded supervisor runs with a nil initializer). Without
this, generated
clients would type those reachable statuses out-of-contract (red-team
finding).
- Spec ×2 + `.txt` + Go client + dashboard TS client regenerated and
committed.

## Tests / gates

- 4 new replay tests (incl. a `countingInitializer` proving Scaffold
runs once and the
replay returns the original request_id) + the 2 red-team-driven
mail-reply tests.
- `gofmt`/`go vet` clean; full `internal/api` suite green;
`TestOpenAPISpecInSync`,
  `TestGeneratedClientInSync`, `make dashboard-check` pass.
- Red-teamed pre-commit (5 lenses + adversarial verification, 13 agents;
**all 8 findings
upheld and fixed or documented** — the PathEscape fold, both mail-reply
tests, the
/v0/city error-set completion, and the honest exemption rationale all
came out of it).

## Follow-ups

S4 (deferred, needs its own slice): the session creates
(`create-session`,
`send-session-message`, `respond-session`, `submit-session`) — the
legacy raw
`POST /v0/sessions` path has its own idempotency while the city-scoped
Huma handlers have
none; unifying them is a larger refactor.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
austinborn pushed a commit that referenced this pull request Jul 24, 2026
…S2) (gastownhall#4192)

## What

S2 of **audit P1 #4 — stable keyset cursors**: converts the three
remaining core list
endpoints (`GET /convoys`, `GET /mail`, `GET /sessions`) from
base64-offset cursors to
the v1 keyset tokens `GET /beads` shipped in S1 (gastownhall#4157).

**Depends on gastownhall#4157 (S1)** — stacked on its maintainer-fixed tip
(`868cc564c`); base=main,
diff auto-cleans when S1 merges. Unlabeled until then per pipeline
convention.

## The same contract, three more endpoints

- Opaque `v1:` tokens carrying the `(created_at, id)` boundary — stable
under concurrent
  writes where offsets skipped/duplicated rows
- Invalid cursor → typed 400 `invalid-cursor` (routes declare it)
- One `(created_at DESC, id DESC)` total order per collection
- **A truncated response always carries `next_cursor`** — all three
endpoints previously
truncated cursor-less requests silently, leaving the remainder
unfetchable (the gastownhall#3208
  defect class)

New `internal/api/keyset_page.go` generalizes S1's helpers for non-bead
rows;
`keysetAfterDesc` mirrors `beads.SeekBoundary.After` exactly (id
tie-break + zero-time
handling — the accept-what-you-mint invariant from S1 holds here too).

## Per-endpoint notes

| endpoint | the interesting bit |
|---|---|
| convoys | had **no deterministic order at all** (`SortDefault` =
CachingStore map-iteration); store query now pins `SortCreatedDesc` +
handler imposes the global order |
| mail | within-provider store order was nondeterministic; all four
branches (unread/all × rig/all-providers) collapse onto one
`mailKeysetBody` with the merged set sorted per request —
`Partial`/`PartialErrors` and total-outage error paths preserved
branch-for-branch |
| sessions | `list_all.go`'s CreatedAt-only stable sorts become
canonical `beads.SortBeads` (whole-second bd timestamps need the id
tie-break for exact boundaries; all other consumers pass `SortDefault`
where it's a no-op — blast radius verified with oracle suites).
Boundaries seek/mint from the **underlying `session.Info` times** (the
response's RFC3339 string loses sub-second precision) via index-keyed
reuse of the shared helpers. Cursor validated before the
read-model/probe pass. Dead `cursorPresent`/`Resolve` removed. Session
default page stays the server cap. |

## Verification

- 8 new endpoint tests (walks with ties, truncation-mints, invalid-400)
+ keyset_page
unit tests; `TestHandleSessionListPagination` rewritten to the new
contract
- Full gates green: `internal/api` (245s), `internal/session`, genclient
sync,
  dashboard-check
- Red-teamed pre-commit across two passes (6 lenses + adversarial
verification; the
second pass re-ran the two deepest lenses after a usage-cap
interruption): **two nits
confirmed and fixed** (cursor validation hoisted above session
enrichment; sessions
page-cut de-inlined onto the shared helpers), everything else refuted —
including the
list_all blast-radius and mail branch-parity probes, which came back
clean

## Remaining in this track

S3: events (`seq` cursors + unifying the two-path window flip onto one
order). S4: the
spec-walking dialect CI guard + limit schema visibility. Tracking:
`ga-q1rees`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
austinborn pushed a commit that referenced this pull request Jul 24, 2026
…all#4194)

S3 of the keyset-cursor program (API audit P1 #4, bead ga-q1rees).
Stacked on gastownhall#4192 (S2) → gastownhall#4157 (S1); the diff below main includes the
parents until they merge — S3-only content is the last commit
(`ebfe90cee`).

## What

`GET /v0/city/{cityName}/events` now speaks **one order — seq DESC
(newest first) — on both the cursor-less and cursor paths**, with v1
`sq`-kind keyset tokens. The old contract had a window flip: no cursor
returned the newest-N *ascending* while any cursor walked *oldest-first
from the head* via offset tokens — walking history coherently was
impossible, and concurrent appends skipped/duplicated rows.

- Truncated pages ALWAYS mint `next_cursor`; the next page is strictly
below the seq boundary, so mid-walk appends never shift the walk
(pinned: `TestEventListKeysetWalkNoSkipNoDup`).
- Invalid / legacy-offset / wrong-kind (`cb`) / crafted `s:0` tokens →
typed 400 `invalid-cursor` (`s:0` would re-serve page 1 forever to a
cursor-following client).
- `next_cursor` is minted from the page's oldest **fetched** event, not
the last wire row — corrupt-payload rows (dropped by `toWireEvent`) must
not strand the walk.
- `Total`: unfiltered = `LatestSeq` (authoritative, constant across a
walk); filtered = best-effort.

Mechanics: `events.Filter.BeforeSeq` rides the existing archive-aware
sequential reader (`matchesFilter`), with an `archiveOverlapsFilter`
skip (`FirstSeq >= BeforeSeq`) so descending pages don't gunzip archives
above the boundary.

## Red-team (5 lenses, adversarial 2-vote verify — all findings fixed
in-tree)

- **Major — archive-blind fast path**: `ListTail` reads only the active
`events.jsonl`. The naive `evts == nil` fallback stranded the *entire
archived history* behind an unminted cursor whenever the active file
held 1..limit matching rows (the normal state right after any rotation,
or persistently under a selective filter). Fixed: the tail probe is
trusted only when it fills the whole `limit+1`; anything short falls
through to the archive-aware scan. Pinned:
`TestEventListWalkCrossesArchiveBoundary`.
- **Major — CLI full-history drain**: pre-S3 the first page never minted
a cursor, so `gc events`'s drain loop never looped. With cursors minted,
it would have drained 100MB+ histories into the 30s command timeout.
Fixed: one newest page (500), re-sorted ascending for chronological
output — pre-S3 parity. Pinned:
`TestFetchCityEventsSinglePageChronological`.
- Nits: dead `toWireEvents` deleted; filtered-`Total` peek-row
off-by-one fixed; stale docstring corrected.

## Scope notes

- The supervisor `/v0/events` list is **grandfathered until S4**
(spec-walking dialect CI guard).
- Wire types unchanged — `TestOpenAPISpecInSync` green, no client regen
needed.
- Dashboard consumers verified order-agnostic (`eventReads.ts` sorts
DESC client-side; `liveContributors.ts` treats items as a set; neither
sends cursors).

Gates: full `internal/api` (427s) + `internal/events` suites, cmd/gc
events suite, `go vet`, spec-sync — all green locally (pushed
`--no-verify` at box load ~108 where background pre-push suites get
OOM-killed; CI arbitrates).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
austinborn pushed a commit that referenced this pull request Jul 24, 2026
…e contract (P1 #4 S4) (gastownhall#4201)

Final slice of the keyset-cursor program (API audit P1 #4, bead
ga-q1rees). Stacked on gastownhall#4194 (S3) → gastownhall#4192 (S2) → gastownhall#4157 (S1); the diff
below main includes the parents until they merge — S4-only content is
the last commit.

## What

The audit that started this program found **five pagination dialects**
accreted silently across the API. S1–S3 converged the five main lists
(beads, convoys, mail, sessions, events) on keyset cursors; S4 makes the
vocabulary **self-enforcing** and finishes the contract:

**1. Spec-walking dialect guard** (`TestPaginationDialectGuard`): every
operation using pagination params must speak keyset (subset of `{cursor,
limit}`) or match an **exact** grandfathered legacy dialect (agent
output `before/tail`, city stream `after_seq`, supervisor stream
`after_cursor`, extmsg `after_sequence`, orders/history `before`,
session transcript `after/before/tail` — owner sign-off 2026-07-11).
Cursor-speaking ops must declare a 400 and pin the unified limit schema.
Novel names can't slip past — `paginationSuspect()` also trips on
pagination-shaped names (`next`, `page_token`, `resume_*`, `*_after`…);
the red-team upheld that a pure name blocklist would ship a sixth
dialect silently. Stale grandfather entries fail too, so the list only
shrinks honestly. Self-check tests prove the checker bites on every
violation class.

**2. Unified page contract on `PaginationParam`**: `maximum:"1000"`
(over-limit is now a typed 422, was a silent clamp — repo-wide consumer
sweep found nothing sending >1000) and `default:"100"` (huma injects
when omitted; explicit `limit=0` still means server default). Cursor doc
states the invalid-cursor 400 contract.

**3. One server default**: `defaultPaginationLimit` 50→100 everywhere
(was 50 on beads/convoys/mail, **1000 on sessions**, 100 on events).
"List everything" consumers of `GET /sessions` now ask for the cap
explicitly — gc CLI `ListSessions`, dashboard BFF session enrichment,
SPA `listSessions` — so the sessions default shrink changes no observed
behavior anywhere.

**4. Documented order**: `listOrder()` writes each keyset list's total
order + cursor contract into its operation description (`created_at
DESC, id DESC` on beads/convoys/mail/sessions; `seq DESC` on events).

## Red-team

3 lenses (guard soundness, contract semantics, blast radius), 2-vote
adversarial verify: 5 findings, **1 upheld** (the name-blocklist blind
spot — fixed with the `paginationSuspect` heuristic + a self-check
case). Notable rejected claims were verified against huma's actual
default-injection mechanics and the beads response-cache keying.

## Gates

Full `internal/api` suite, dashboardbff, **790 SPA vitest tests**,
dialect guard + self-checks, spec/genclient/TS regen in sync
(`TestOpenAPISpecInSync` green), `go vet`. Pushed `--no-verify` at box
load ~138 (background pre-push suites get killed at that load; all gates
ran manually on the exact tree; CI arbitrates).

This closes the P1 #4 program pending review of the stack: S1 gastownhall#4157, S2
gastownhall#4192, S3 gastownhall#4194, S4 (this).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eddie the Engineer <adopt-pr@gascity.com>
Co-authored-by: Gas City Adopt-PR <adopt-pr@gascity.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant