Skip to content

Instawards/pay batch sac - #6

Merged
r-json merged 13 commits into
mainfrom
instawards/pay-batch-sac
Sep 11, 2026
Merged

Instawards/pay batch sac#6
r-json merged 13 commits into
mainfrom
instawards/pay-batch-sac

Conversation

@r-json

@r-json r-json commented Sep 11, 2026

Copy link
Copy Markdown
Owner

No description provided.

r-json and others added 13 commits September 11, 2026 11:27
Consolidates the P0–P2 hardening work that was sitting uncommitted in the
working tree. This is a single large commit because the changes were made
without version control present; subsequent work will be committed in
substantive increments.

Contract (contracts/core-flow):
- CFWP-v2 attestation: 198-byte domain-separated preimage binding network,
  contract, worker, token, escrow/payment ids, amount, hours, period, nonce
- proof_preimage() makes the contract the single source of truth for what is
  signed; admin oracle-key registry replaces manager-attestable proofs
- hours * rate == amount enforced on-chain (AmountHoursMismatch)
- admin pinned at build time, two-step admin handover, upgrade requires paused
- per-payment events so indexing a multi-payee settlement is not lossy
- 70 tests (2 ignored: non-unwinding host traps, verified live instead)

Money:
- bigint base units end to end; exact decimal string parser; no JS number in
  any monetary path; rejects excess precision rather than rounding

Payments:
- 16-state machine, 38 actor-scoped transitions, compare-and-swap application
- batch standing derived, never stored
- no endpoint accepts a destination state; PAID is reachable only from chain
  evidence observed by the indexer

Multi-tenancy:
- composite foreign keys on (orgId, id) so Postgres enforces tenant isolation
- 5-role RBAC, 35 permissions; WORKER holds none
- organization, role and approval identity are always derived server-side

Reconciliation:
- verifies against SAC transfer events, independently of CoreFlow's own
  projection; transaction-scoped settlement matching
- run locking via a partial unique index on status = 'RUNNING'

Payroll CSV (new):
- exact-decimal parser; rejects scientific notation, excess precision,
  zero/negative amounts, fractional hours and unsettleable assets
- spreadsheet formula injection neutralized at the storage boundary
- size and row caps; one Payment per row, never an aggregate
- batch creation idempotent via a unique index on (orgId, idempotencyKey)

Environment safety:
- scripts/check-env.mjs: fail-closed preflight, explicit allowlists, wired
  into dev/db:migrate/db:deploy/db:seed. Added after a `vercel env pull`
  silently repointed local development at the production database and
  Mainnet v1
- .gitignore now denies every .env variant rather than three named ones

Not done in this commit:
- migration 20260911040000 is written but NOT applied (no local database)
- production remains on the v1 schema; see
  docs/PRODUCTION_DATABASE_REMEDIATION.md (proposal only, nothing executed)
- secret rotation for everything exposed by prodenv.txt is still outstanding

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or model

Implements the Bulk Pay API. No database-backed validation: migration 8 remains
unapplied and no query here has run against real PostgreSQL.

Routes:
- POST   /api/payroll/batches             create a draft, one Payment per CSV row
- GET    /api/payroll/batches             list, tenant-scoped, cursor paginated
- POST   /api/payroll/batches/validate    stateless preflight, writes nothing
- GET    /api/payroll/batches/:id         detail, standing derived on read
- POST   /api/payroll/batches/:id/validate  re-check a draft against current config
- POST   /api/payroll/batches/:id/approve   record the caller's half of the gate

Per-payment action routes were already present and are unchanged. There is still
no endpoint that accepts a destination PaymentState.

Request schemas (zod, every object .strict()):
- unknown fields are rejected, not ignored: {state:'PAID'} and {role:'FINANCE'}
  both produce 400 UNKNOWN_FIELD rather than being silently dropped
- no schema accepts a role, state, settlement status, approval identity, or an
  amount in any form other than the uploaded file
- control characters refused in stored free text rather than stripped

Authorization:
- every route goes through withTenant(); no route-local tenant checks
- approval uses a new central requireAnyPermission(), because either half of the
  dual-approval gate is a legitimate approver and gating on one rejects the other
- cross-tenant access returns a byte-identical 404 to a non-existent resource

Idempotency:
- (orgId, idempotencyKey) unique index is the guarantee; the pre-check is only an
  optimization, and the lost-race path is tested with the pre-check blinded
- a reused key with a DIFFERENT payload is refused (409) rather than replaying a
  batch the caller did not describe, via a new idempotencyFingerprint column
- byte-identical recent uploads warn, never block: the same figures next period
  are legitimate payroll

Error model (src/lib/api/errors.ts): 400/401/403/404/409/422/429/500/503 with one
envelope. `details` carries only the caller's own input echoed back. A 500 is
always opaque, with no develop-mode switch to reveal internals.

Two real bugs found and fixed:
- approval.create omitted orgId, a REQUIRED scalar of the composite foreign key
  on (orgId, paymentId). It would have failed against real PostgreSQL on the
  dual-approval path. Enforcing required columns in the test double then caught a
  second instance in rejectPayment's approval.upsert.
- draft re-validation read payments as `any`, and TypeScript types `any * any` as
  NUMBER — so the hours x rate exactness check would have been floating point.
  Fixed by declaring RevalidationPayment with bigint fields.

Test double fidelity (these changes made failing tests pass honestly, rather than
assertions being weakened):
- $transaction now keeps a per-transaction undo log. It previously snapshotted
  every table, so one transaction rolling back discarded another's committed
  writes, and the concurrent-idempotency test saw 1 payment instead of 3.
- required-column enforcement for twelve tenant-scoped tables
- findFirst honours orderBy; gte/gt/lte filters; cursor pagination; nested includes

Also: findDuplicateUpload excludes the new batch in the QUERY rather than
filtering afterwards, so same-millisecond creates no longer hide a duplicate.

713 -> 761 TypeScript tests passing, 9 skipped. Rust 70 passing, 2 ignored.
Typecheck clean. Production build succeeds with all five routes registered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…1 integration tests

Establishes a safe local PostgreSQL environment and validates the code against it.
Two real defects found that the unit suite could not see.

Local database:
- scripts/dev-db.sh creates a PRIVATE PostgreSQL cluster owned by the current
  user, in their home directory, on port 5440, listening on loopback only. No
  root, no change to the system instance, no authentication circumvented: the
  cluster's creator is legitimately its superuser. Needed because neither
  `postgres` peer auth nor the old `coreflow` password was available.
- coreflow_dev + a SEPARATE coreflow_shadow, because Prisma resets the shadow
- password generated locally, written only to .env, never printed

Environment file separation:
- database configuration now lives in .env, which BOTH Next.js and the Prisma CLI
  read; .env.local is Next-only and holds no database URL
- production URLs commented out of .env and preserved in .env.vercel
- `vercel env pull .env.vercel --env=production` documented as the only safe form

Migrations — all 10 apply from zero, zero schema drift:
- NEW 20260911015000_finding_kind_values. The reconciliation migration added six
  values to the FindingKind enum and then USED them in the same file. PostgreSQL
  refuses that (55P04: new enum values must be committed before they can be
  used), and migrate deploy wraps each migration in one transaction — so it could
  never have applied. The earlier claim that migrations applied from zero was
  wrong: this one had been applied by hand and marked resolved.
- NEW 20260911044540_composite_fk_no_action. Twelve relations declared
  `onDelete: SetNull` on a composite FK whose first column is orgId (NOT NULL).
  SET NULL nulls every column of the key, so deleting an Escrow, Project or
  Worker that any row referenced failed with "Null constraint violation on the
  fields: (orgId)" — escrow deletion was impossible. `prisma validate` had been
  warning about this. NoAction rather than Restrict, because NO ACTION is checked
  at end of statement and so a cascading delete from Organization still works.

Integration suite (71 tests, separate vitest config so totals cannot be merged):
- constraints (30): 14 composite tenant FKs rejecting cross-tenant rows, unique
  and partial indexes, bigint exactness incl. int8 overflow refusal, cascades,
  real rollback, one transaction not undoing another
- Bulk Pay routes (26): CSV to 3 persisted Payment rows with exact amounts,
  audit event, worker linking, formula neutralization, atomic rollback with the
  fault injected inside the real transaction, 3-way concurrent idempotency,
  key-reuse conflict, reference allocation under contention, approval recording,
  tenant isolation through the API
- state machine (15): DRAFT to PAID persisted with a continuous audit trail, no
  user actor of any role able to persist PAID, PAID never moving backwards,
  compare-and-swap resolving a genuinely incompatible race

Test double: its limitations are now enumerated at the top of fake-db.ts — it
enforces no foreign keys, cascades, column types, overflow, partial-index
predicates or transaction isolation. A passing unit test proves query scoping,
not that the database would refuse an unscoped write.

Three of my own tests were wrong and were corrected, not deleted: a planner
assertion that was data-dependent (and revealed SET LOCAL is discarded outside a
transaction), a race between two transitions that are legitimately compatible,
and an expectation of `orgId` where Prisma names the relation `org`.

Unit 761 passed / 9 skipped. Integration 71 passed. Rust 70 passed / 2 ignored.
Typecheck clean. Build succeeds. Production database untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… race

The concurrency test listed only CONCURRENT_MODIFICATION and INVALID_TRANSITION as
valid outcomes for the losing transition, and failed about half the time. The third
is TERMINAL: when the CANCELLED side wins the race, the other transition is refused
because nothing leaves a terminal state.

The product was correct in all three cases; the assertion was incomplete. Found by
running the integration suite ten times after noticing a single failure among eight
otherwise green runs, rather than treating the green runs as proof.

Now 0 failures in 12 consecutive runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…verification

Stage 4 of the implementation map: the bridge from an approved draft to a funded
on-chain escrow. Domain layer and the indexer handoff; API routes and UI next.

AUDIT FINDING THAT SHAPES THE WHOLE STAGE — creation and funding are ONE atomic
transaction, not two. `initialize_multi_sig_escrow` pulls custody via
TokenClient::transfer(manager -> contract) per distinct asset and only then stores
the escrow; there is no fund() entry point and CoreFlowEscrow has no `funded`
flag. An escrow exists if and only if its custody moved.

Consequences:
- there is ONE wallet interaction, so "created but not funded" cannot be shown,
  because it is not a state the contract can be in
- the contract is NOT idempotent: a second submission creates a second escrow and
  moves the money again. Everything preventing a double charge is off-chain.

Other audited facts: custody destination is the contract's own address; the asset
is per-payment `token` (a SAC address, never inferred from a symbol); creation
requires manager != finance_approver (SignersNotDistinct), a registered oracle
key, end_date > start_date, and amount % rate_per_hour == 0; creation proof is the
`escrow/created` event plus per-payment `payment/add` events; funding proof is the
SAC's own transfer event; current state reads via get_escrow.

eligibility.ts — pure, 34 tests. Refuses: a role without escrow:create, an
already-funded batch, an attempt in flight, payments not in DRAFT/VALIDATING,
payments already on-chain, an unconfigured or mismatched settlement asset, mixed
assets, non-positive amounts, amount != hours x rate, >100 payments, a
non-distinct finance approver, an unavailable oracle key, and — newly surfaced —
a MISSING PAY PERIOD. The contract requires end > start and the period is a signed
field of the oracle proof, so it cannot be filled in: attesting to a pay period
nobody stated is invented financial data.

service.ts — 26 tests. A BlockchainTransaction opened BEFORE the wallet, keyed
fund:batch:<id>, whose unique index is what makes double-click, refresh and two
tabs converge on one escrow. Payments move DRAFT -> VALIDATING at intent.

confirmFunding re-reads the chain and has four outcomes, deliberately distinct:
  CONFIRMED     tx succeeded, escrow matches the plan payment-by-payment, and a
                transfer of the exact total reached the contract in THAT tx
  FAILED        the chain says the tx failed; nothing moved, payments return
  UNVERIFIABLE  the chain could not be read — NOT a failure, retry later
  MISMATCH      the chain disagrees; the escrow is NOT adopted as this batch's

Freighter returning is not evidence. The client builds and signs, so it could
submit something other than the plan; only the read-back records funding.

Abandoning an attempt returns payments to DRAFT only when nothing was submitted.
Once a hash exists the money may have moved, so the record is not rewound.

Indexer: a payment the application already owns now advances VALIDATING ->
AWAITING_ORACLE when payment/add is observed. Previously only rows the indexer
itself invented got a state, so a funded CSV payroll would have sat in VALIDATING
forever. ApplyResult gains paymentsAdvanced, kept distinct from paymentsCreated.

Two bugs found by my own tests, both ordering: the in-flight check had to precede
the eligibility gate (an open attempt is reported as a blocker, so it rejected the
request it should have satisfied), and absence of a hash must be `== null`.

Migration 11 adds BlockchainTransaction.batchId — escrowId cannot identify a
funding attempt, being null until the escrow it creates exists.

Unit 761 -> 821 passed, 9 skipped. Integration 71 passed. Typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Funding intent becomes a financial idempotency boundary with a frozen plan, and
the five funding routes land. UI and live Testnet run still outstanding.

Immutable plan (migration 12):
- BlockchainTransaction.plan + planDigest. The exact plan is persisted when the
  intent opens, BEFORE any wallet is shown, and never rewritten: batch, org,
  project, contract, custody destination, network, manager, finance approver,
  oracle key, asset, total, and every row's payment id, recipient, token, amount,
  rate and period. Money as decimal strings — JSON has no bigint.
- Confirmation compares chain evidence against THAT, never a recomputed plan.
  Configuration can move under a pending transaction (switched settlement asset,
  a different finance approver becoming first candidate, an edited payment) and a
  recomputed plan would quietly agree with whatever the chain contained.
- planDigest is SHA-256 over a canonical rendering; a plan that does not match its
  digest is refused rather than trusted.

Verification now also catches: a finance approver that is not the planned one, an
escrow whose manager and finance approver are the same key, a payment edited or
removed since the plan was frozen, and a network or contract that does not match.
Custody is checked from the PLAN's manager, not the caller's wallet, so a
different administrator recovering an uncertain transaction reaches the same
verdict. A MISMATCH opens a CRITICAL ReconciliationFinding preserving the
evidence, and adopts nothing.

Period required at upload, not at funding:
- period_start and period_end are now REQUIRED CSV columns. The period is a signed
  field of the CFWP-v2 attestation and the contract refuses end_date <= start_date,
  so a row without one can be drafted but never funded. Discovering that at the
  wallet prompt, after a payroll is approved, is far worse than at row 8.
- CoreFlow still will not supply one. Defaulting to today, last month or the upload
  date means attesting to a pay period nobody stated.

API — five routes, thin over the domain service:
  GET  /funding            assessment + frozen-plan-shaped disclosure, read-only
  POST /funding/intent     opens the attempt; 201 new, 200 recovered
  POST /funding/submitted  records a hash; does NOT mark funded
  POST /funding/confirm    chain verification; 200 for every outcome
  POST /funding/abandon    narrow; never asserts an escrow does not exist

Every attempt id is scoped to the batch in the URL, so an attempt belonging to
another batch cannot be acted on by naming it. No request body carries an amount,
recipient, asset, manager or finance approver.

Found while wiring this: tsconfig.json excludes **/*.test.ts and **/__tests__/**
from typechecking, so adding a required `batchId` parameter did not fail any test
compile — and `where: { batchId: undefined }` means "no filter", so the tests
silently stopped exercising the new scoping. Call sites fixed and a test added
that an attempt is not actionable through a different batch.

docs/FUNDING.md documents the one-transaction architecture, the idempotency
boundary, the frozen plan, the four confirmation outcomes, the retry policy for
uncertain transactions, and the period requirement.

Unit 821 -> 834 passed, 9 skipped. Integration 71 passed. Typecheck clean.
Build succeeds with all five funding routes registered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…in-tx safety

The funding review screen, driven entirely by the server-issued frozen plan.

Browser never constructs a financial intent:
- src/lib/funding/client.ts is a thin typed transport. Nothing in it computes a
  total, converts a decimal or decides what an escrow contains. planToContractArgs
  maps the plan 1:1 with no arithmetic and no reordering — order is load-bearing,
  since verification compares the on-chain payment vector against the plan by index.
- FundingPlan money is now a decimal STRING on the wire. It was bigint, which means
  NextResponse.json on GET /funding would have thrown "Do not know how to serialize
  a BigInt" — a guaranteed 500 that no test covered. Added a test asserting the
  funding state survives JSON.stringify, both eligible and blocked.

One transaction, never two:
- no "create escrow" action exists anywhere in the UI
- the header states that creating and funding happen in the same Stellar
  transaction, before the wallet opens

Uncertain transactions:
- CoreFlowClient.submitInitializeEscrow gains an onSubmitted callback, fired the
  instant the network accepts the transaction and BEFORE polling. Previously the
  hash was only returned after polling, so a failure while waiting lost the only
  record of a transaction that may already have moved money — and the obvious
  recovery would have been to sign a second one.
- while an attempt is unresolved there is NO funding button at all, only a
  read-only "Check status", and the panel says plainly: "Do not fund this payroll
  again." The contract is not idempotent; a second signature funds a second escrow.
- on mount the existing intent is recovered, never replaced, so a reload during
  submission lands on the same attempt.

Outcomes are rendered as the server reports them. UNVERIFIABLE is never shown as
failure; MISMATCH says the escrow was not attached and offers no retry.

Disclosure shows recipients, total, network, destination contract, manager, the
distinct finance approver, a quotable plan reference (CF-PLAN-xxxxxxxx from the
digest), every payment row with its exact base-unit amount, and collapsed
technical details. Every phase change is announced via an aria-live region.

Bug found by the panel tests: after opening the intent the component still read
the pre-intent state, so the plan reference never rendered. The attempt is now
tracked directly rather than re-read from stale state.

Also: docs/BACKLOG.md records the test-file typechecking gap with a concrete
migration plan, plus six other deferred items, so none of them silently persist.

Unit 834 -> 847 passed, 9 skipped. Integration 71 passed. Typecheck and build
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he recovery dead end

The gap: transaction accepted -> hash persisted -> return value unparseable ->
escrow id unknown -> VERIFYING -> Check status could not complete. Signing again
would have funded a SECOND escrow with the same money, so that dead end was the
most dangerous path in the flow.

The transaction hash is now the durable anchor. resolveEscrowFromTransaction reads,
in order of durability:
  1. ChainEvent — the indexer's own escrow/created record, scoped to the attempt's
     contract AND network. Survives RPC event retention.
  2. Soroban RPC — authoritative but retention-bounded, for a transaction the
     indexer has not reached yet.

ChainVerifier gains findEscrowsCreatedByTransaction, which returns every match so
the caller can refuse ambiguity rather than pick one.

Outcomes: exactly one escrow -> verify; none visible yet -> UNVERIFIABLE (pending,
never failed); more than one -> MISMATCH, refusing to choose.

Security: onChainEscrowId is now OPTIONAL and ADVISORY. The server uses what it
resolved from the hash; a client-supplied value is only cross-checked, so
transaction A cannot adopt an escrow created by transaction B. A disagreement is a
MISMATCH recording both ids. An indexed event from another contract or network is
ignored rather than trusted.

Recovery is idempotent: confirming twice yields one escrow, one attempt, one
funding.confirmed audit event, and creates no new attempt.

The panel now verifies with only the attempt id, so an unreadable return value
moves to CONFIRMING and completes rather than stalling.

Tests: 7 new cases including the mandatory one — transaction accepted, return value
unreadable, hash persisted, escrow resolved from the event, existing intent
confirmed. Plus indexer-record preference when RPC is unreadable, pending
visibility, repeatability, wrong-id refusal, ambiguity refusal, and foreign
contract/network events being ignored.

Unit 847 -> 854 passed, 9 skipped. Integration 71 passed. Typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed rates

The batch detail workspace at /dashboard/payroll/[id], plus the automatic recovery
§5 asked for.

Automatic recovery: a submitted attempt with a known hash and no escrow id is now
resolved on arrival, once, with no user action and no second signature. Guarded by a
ref so a re-render cannot re-enter it, and deliberately not retried on a loop —
re-verifying a genuinely pending transaction is noise, and Check status remains.

Batch detail:
- header with the domain status mapped to words a finance user reads; UNVERIFIABLE
  is never rendered as "Failed"
- summary figures formatted server-side, exact base units in technical details only
- the funding card is the same FundingPanel, so the three funding states and the
  recovery behaviour are identical wherever they appear
- ONE TABLE ROW PER PAYMENT, never an aggregate, with a drill-down to the exact
  stored base units, on-chain index, approvals and transaction — and "not yet
  available" where a transaction cannot exist
- approvals read from Approval RECORDS, never inferred from payment state, and both
  halves always shown including the missing one: "waiting for finance approval" is
  the fact a reviewer needs, and listing only approvals that exist hides it
- activity timeline from real AuditEvent rows and nothing else. A test asserts that
  plausible-sounding events nobody recorded — "CSV reviewed", "Oracle verified",
  "Manager reviewed", "Finance reviewed" — do not appear. Empty says so plainly.
- reconciliation findings surfaced with severity, first/last seen and remediation,
  and an explicit note that a finding records a disagreement and does not by itself
  mean a payment failed
- a cross-tenant batch renders the same "not found" as a non-existent one

Defect found by the tests: the Rate column rendered raw base units, so a $25/hour
rate displayed as 250000000. presentBatch now sends a formatted rate alongside the
exact value, for the same reason the amount is formatted server-side — the
alternative is dividing in the browser.

The detail endpoint now also returns the activity timeline and open findings, both
scoped by the same organization filter the batch was resolved with.

Mandatory regression test added: existing attempt, hash present, escrow id absent,
page loads, recovery resolves the escrow, page becomes Funded, and zero calls to
/intent, /submitted or /abandon — nothing new created, no signature requested.

Unit 854 -> 871 passed, 9 skipped. Integration 71 passed. Typecheck and build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cle key

No transaction was submitted. No funds moved. Simulation refused the escrow with
Error(Contract, #16) OracleKeyNotRegistered.

Two real findings, both surfaced only by attempting the live run.

1. THE ORACLE KEY THE CONTRACT TRUSTS IS NOT THE ONE IN THE ENVIRONMENT.
   is_oracle_key_registered(3b9d395a...) — local env  -> false
   is_oracle_key_registered(f42a4883...) — deployment -> true
   Consistent with the oracle secret having been rotated locally without the new
   public key being registered on-chain. NO key was registered or revoked: which
   key is post-rotation is a fact only the operator holds, and registering the
   wrong one would re-authorize a credential that may be compromised — precisely
   what the admin registry exists to prevent. The outstanding secret rotation is
   now on the critical path.

   The contract refusing an unregistered oracle is the control working. A manager
   cannot install their own oracle, and that held against a real transaction.

2. PRISMA LOADS .env AND ONLY .env, so any script, test or migration importing it
   inherited .env's NEXT_PUBLIC_STELLAR_NETWORK="public" and the v1 MAINNET
   contract — while `next dev` looked correct, because Next loads .env.local first
   and dotenv never overwrites an existing variable. The live test's own guard
   caught it before anything ran.
   - .env network keys corrected to the v2 Testnet deployment
   - check-env.mjs now validates the .env-ONLY view as well as the merged one, so a
     Mainnet value reachable by Prisma is an error even when .env.local is right.
     The previous version merged files the way Next.js does and reported OK.

Also: the live test's CLI helper now surfaces the contract's own error instead of
"Command failed", which is what turned an opaque failure into a diagnosable one.

The run did establish, before stopping: preflight on local PostgreSQL + Testnet v2;
a 3-payment payroll from a real CSV through the real parser and batch service with
exact base units (10000000 + 15000000 + 5000000 = 30000000); both halves of the
approval gate as real Approval rows from two distinct wallets; a frozen funding plan
whose SHA-256 digest matches its content; and a manager balance of 77,120 test USDC,
so funds were never the blocker.

Evidence: docs/evidence/testnet-v2-live-funding.json, labelled ATTEMPTED/BLOCKED and
kept separate from unit, integration and historical Mainnet evidence.

Unit 871 -> 873 passed (19 skipped incl. the 10 live cases). Integration 71 passed.
Typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… runbook

Environment invariant: check-env now validates the COMBINATION, not only each
variable. Only two profiles are permitted —

  LOCAL       local database + Testnet + CoreFlow v2
  PRODUCTION  production database + Mainnet + CoreFlow v1

anything else is MIXED, which is how local development came to be pointed at the
production database and Mainnet v1. The profile is printed on every run.

The first version of this check was gated behind the override flags, which made it
unreachable — dead code shaped like a control. It now fires in both directions:
an error with no override, a loud note when an override is explicitly set, since
an override means the operator said they meant it. Verified reachable: deliberate
local-DB + Mainnet-v1 with COREFLOW_ALLOW_MAINNET=1 reports MIXED as a note, the
same combination without the override refuses, and a normal local environment
still passes.

docs/ORACLE_KEY_TRANSITION.md: a prepared, UNEXECUTED runbook for the oracle
registry transition — register the new key first, verify from chain state rather
than the CLI exit code, only then revoke the old key, verify again, and stop if
either post-state does not match. Registering first means there is never a window
with no registered key, which revoking first would create and which would strand
every escrow awaiting an attestation.

It is not executed because which public key is post-rotation cannot be determined
from here. The naming suggests an answer and the naming is not evidence: if the
mapping is the other way round, registering would authorize a credential an
attacker may hold to sign work attestations — exactly what the admin registry
exists to prevent. A manager cannot install their own oracle; neither can an agent.

BACKLOG: the secret rotation item now records that its oracle half is on the
critical path, with a pointer to the runbook.

No chain transaction sent. No secret read or printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ence record

scripts/oracle-key-transition.mjs — UNEXECUTED. Makes the registry transition one
reproducible pass that writes its own evidence record, rather than a sequence of
commands with a narrated summary afterwards.

Safeguards:
- refuses unless BOTH public keys are named AND --confirm-mapping is passed, because
  which key is post-rotation cannot be determined from here
- refuses a malformed key
- verifies get_admin matches the signing identity before anything is sent
- registers BEFORE revoking: revoking first would leave the contract with no
  registered key and strand every escrow awaiting an attestation
- verifies from CHAIN STATE after each step, never the CLI exit code
- if the new key is not registered after registration, stops and does NOT revoke
- if the final state is not new=registered/old=not-registered, stops without a
  corrective transaction and writes the record for review
- prints only public values: network, contract, admin address, oracle public keys

Evidence record captures the intermediate post-registration state as well as the
final one — that is what shows there was never a window with no registered oracle.

Verified: refuses without --confirm-mapping (exit 1), refuses a malformed key. The
dry-run path is deliberately unexercised, since running it would mean asserting a
mapping that has not been confirmed.

No chain transaction sent. No secret read or printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… excluded

Rotates the secrets exposed by prodenv.txt and proves the old value can no
longer authenticate. The script emits its own evidence record, because a
rotation that cannot be demonstrated has not been finished.

A generated secret goes from crypto.randomBytes straight into the stdin of
`vercel env update`, which stores it write-only. It is never echoed, written to
a file, or passed in argv. What is printed is a truncated SHA-256 fingerprint,
so two environments can be compared without either value being readable.

ORACLE_SECRET_KEY is refused outright. Its public half is derived from the
secret and registered on chain, so rotating it is not an environment change but
an on-chain one — the change currently blocked on the owner confirming the key
mapping. Minting a third oracle key would also destroy the evidence needed to
decide which of the two existing keys is exposed.

Corrected the exposure scope rather than inheriting it. Enumerating the dump's
variable names shows CRON_SECRET and INDEXER_SECRET are not in it; BACKLOG.md
item 7 claimed they were. They remain worth rotating as hygiene, but treating
them as breach response misdirects the effort.

Three guards exist because the first drafts were wrong:
  - BOOTSTRAP_SECRET is not probed at all. On a secret match the route proceeds
    to prisma.user.upsert defaulting to ADMIN_WALLETS[0], so a probe that
    succeeded would grant admin in production — the check would cause the thing
    it checks for. It is also undecidable externally: 404 is returned whether
    the secret is unset or merely wrong.
  - A probe path absent from src/app is refused, because a mistyped path also
    404s and would otherwise read as a rejected credential.
  - A stdin value under 32 characters is refused, so a placeholder cannot
    manufacture an evidence record asserting a rejection that never applied to
    the live secret.

Also records, unfixed, that indexer/run guards the same secret more weakly than
reconciliation/run: plain string comparison, no minimum length, no rate limit.
Authentication code is not changed mid-rotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

Deployment failed for project coreflow with the following error:

Hobby accounts are limited to daily cron jobs. This cron expression (*/10 * * * *) would run more than once per day. Upgrade to the Pro plan to unlock all Cron Jobs features on Vercel.

Learn More: https://vercel.link/3Fpeeb1

@r-json
r-json merged commit 8f4f227 into main Sep 11, 2026
0 of 5 checks passed
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