Skip to content

M6 FDQ-73 — expiry/terminal reconciliation sweep (grey_two) - #33

Merged
Mayakovsky merged 2 commits into
mainfrom
m6-fdq73-reconciliation
Jul 25, 2026
Merged

M6 FDQ-73 — expiry/terminal reconciliation sweep (grey_two)#33
Mayakovsky merged 2 commits into
mainfrom
m6-fdq73-reconciliation

Conversation

@Mayakovsky

Copy link
Copy Markdown
Owner

Follows #32 (C′). Closes the FDQ-73 gap: the SDK never delivers job.expired/job.rejected to the adapter (acpAgent.fireHandler gates on shouldRespond, whose RESPONDERS map omits job.expired and sets job.rejected:[]), there's no client-side expiry timer, and the delivery poll is FUNDED-only — so a submitted-then-terminal job that isn't completed strands as status='submitted' and no stiff is ever recorded. The reputation ladder can't advance without this sweep, so it's a hard prerequisite for flip-to-enforce.

What it does

Each poll tick: listExpiredSubmitted(now) (bounded, oldest-first SELECT of still-submitted rows past expires_at) → for each, fetch the job's true on-chain status → discriminate:

  • expired → resolve + one stiff (via the gate's idempotent onJobTerminal, clean→warned→…)
  • rejected / completed → resolve, no stiff
  • still funded / open / unknown → left submitted, retried next tick

Port-mapping rationale

Ported from plugin-acp reconcileTrackedJobs (read-only under lock #5), with one deliberate change: it triggers on expires_at < now (bounded SELECT) rather than "dropped out of the live active set." That removes the source's "dropped-from-active but actually still live" ambiguity — the authoritative-status check is the sole discriminator, so a past-SLA row that's still funded on-chain maps to null and is left alone. Marking a rejected job as expired-and-stiffed would be a false strike; this prevents it by construction. No duplicated resolve/stiff logic — it reuses the gate's onJobTerminal (which owns resolveIfSubmitted's WHERE status='submitted' idempotency guard + stiff-only-on-expired).

Safety

  • Never signs — reads job status + writes reputation tables only (zero setBudget/submit/reject); safe under OBSERVE_ONLY.
  • Fail-soft — a sweep-query error skips the tick; a per-row error skips that row; nothing throws into the poll loop.
  • Idempotent — a real event (or a concurrent sweep) that also resolves the row is a no-op for the second observer.
  • Grant-compliant (FDQ-65) — new statement is SELECT-only; resolve/stiff go through the existing UPDATE/upsert.
  • Runs on the existing poll cadence; cleared by stop().

Fail-safe empirical note (verified in soak, not a blocker)

toTerminal assumes getJob returns expired/rejected/completed. If the API uses other tokens → toTerminal returns null → the row stays submitted (under-resolves — never a false stiff). Confirmed against a real stranded job during the post-redeploy soak (a controlled expiry test), adding any missing token as a one-line follow-up.

Gates

vitest run 50/50 (10 new) · tier-1 smoke ✓ · typecheck ✓ · build ✓ · lint ✓

Sequencing

C′ (#32) merged → this PR → one box redeploy carrying both (separate authorized step): BUYER_GATING_BLOCK_ENABLED=false, verify reputationGate: 'shadow' + sweep running + seller healthy on 0xa966… + grey stopped.

Mayakovsky and others added 2 commits July 25, 2026 15:46
The SDK never delivers job.expired/job.rejected to the adapter (acpAgent.fireHandler gates
on shouldRespond, whose RESPONDERS map omits job.expired and sets job.rejected:[]), there is
no client-side expiry timer, and the delivery poll is FUNDED-only — so a submitted-then-
terminal job that isn't `completed` strands as status='submitted' and no stiff is ever
recorded. The reputation ladder can't advance without this sweep; it is a hard prerequisite
for flip-to-enforce.

- reputationDb.ts: add listExpiredSubmitted(nowIso, limit) to TrackedJobsRepo — a bounded,
  oldest-first SELECT of still-`submitted` rows past expires_at. SELECT only (FDQ-65).
- reputationReconciler.ts: each poll tick, pull expired-submitted rows, fetch each job's TRUE
  on-chain status, and resolve via the gate's idempotent onJobTerminal — a stiff only for a
  genuine `expired`; `rejected`/`completed` resolve with no stiff; still-live/unknown left
  `submitted` and retried. Ported from plugin-acp reconcileTrackedJobs but triggered on
  expires_at (not active-set dropout), removing the "dropped-from-active but still live"
  ambiguity — false-strike prevention by construction. Reads status + records only; NEVER
  signs (safe under OBSERVE_ONLY). Fail-soft at tick + per-row.
- acpAdapter.ts: reputationReconciler seam; sweep on the existing poll cadence (cleared by
  stop()); fetchJobStatus via the SDK getJob (jobStatus ?? status convention).
- main.ts: share the gate's trackedRepo, build + inject the reconciler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- test/reputationReconciler.test.ts: 10 deterministic tests (injected clock + fake tracked
  repo + injected status fetcher) — terminal discrimination (expired→one stiff clean→warned;
  rejected/completed→resolve no stiff; still-funded + not-yet-expired→untouched), idempotency
  (two sweeps → one stiff), fail-soft (list throws → tick skipped; per-row getJob throw → row
  skipped, loop continues), and listExpiredSubmitted grant compliance (bounded SELECT — no
  DELETE/TRUNCATE/UPDATE/INSERT).
- test/buyerReputationGate.test.ts: interface-conformance for the fake tracked repo.

vitest run 50/50, tier-1 smoke, typecheck, build, lint all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Mayakovsky
Mayakovsky merged commit a4bdab1 into main Jul 25, 2026
3 checks passed
@Mayakovsky
Mayakovsky deleted the m6-fdq73-reconciliation branch August 14, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant