Skip to content

feat: JobExecutionDTO read model; job detail stops reading mock data (PX-6) - #353

Merged
LamaSu merged 27 commits into
masterfrom
feat/readmodels-job-execution
Sep 30, 2026
Merged

LamaSu merged 27 commits into
masterfrom
feat/readmodels-job-execution

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

PX-6 (product pack §7, gateway read-model lane). The live Jobs list drilled into a job detail page built from mock data, and the gateway's own job DTO reported completed as settled. This PR adds a typed read model for one job and makes the detail page a projection of it.

Stacked on #313 (it uses the canonical money map, classifyMoneyStatus). The PR base is master so CI runs. Until #313 merges, its 3 commits also show in this diff. This PR's own commits are 3ea0cd4e, 506b2dc2, 1a80846a and 562f0578.

What was wrong (at ac86a404)

  • JobDetailPage read only mockJobs, jobMeta and mockEscrows, which are empty since mock-data was emptied, so every real job showed "Job not found". Its only content was a hard-coded evidence array, hard-coded DID, IPFS CID and Base/Solana tx hashes, and a "Load demo trace" fixture.
  • job.populator.ts buildTimeline emitted a settled event for any completed job, so paid was inferred from completion on the server.
  • In the job-row vocabulary, settled is written by mock settlement with no money moving, and escrows has no jobId, so the job↔escrow link has to be resolved.

GET /api/jobs/:jobId/execution → JobExecutionDTO (@pcc/spec, pcc.job-execution/v1)

The DTO has four independent axes. Each has an explicit enum, names its source, and carries the source's own timestamps. Axes never borrow meaning from each other.

Axis Source States
execution gateway job row pending, queued, dispatched, running, awaiting_handoff, paused, completed, failed, timed_out, cancelled, unknown, from one exact table covering every documented job vocabulary. An undocumented value is unknown, never success.
evidence gateway evidence store none / received / unavailable, with bundle and event counts and fabricated-by-design events counted by the canonical isFabricated. Received is not verified.
verification capture verdicts The outcome is not_available (no public verdict read exists). Capture checks are counted as capture checks only.
settlement gateway escrow record (not a finalized chain read) link linked / not_linked / ambiguous / unavailable; money states via #313's map; payout paid / refunded / not_paid / simulated / unknown

Payout rules:

  • Payout reads the job's own milestone, matched by step.
  • The whole-escrow status is used only when the escrow has no milestones at all, so a sibling step's release never pays this job.
  • A mock-escrow-* escrow is simulated, never paid.
  • More than one candidate record (escrow or milestone) is ambiguous, and nothing is chosen.
  • A failed source read is unavailable with a generic error. It is never an empty list or a default.
  • asOf is the READ time (render-provenance contract #2222/#2232).
  • Same auth gate as GET /api/jobs/:jobId. Under TENANT_ENFORCE, a job belonging to another tenant returns 404.

Also

  • The legacy timeline now emits completed for completed. A row that itself says settled is still echoed.
  • JobDTO rows carry executionPhase, read on the server, so the list never re-derives it.
  • GET /api/jobs keeps { jobs } and adds collection-v1 items (for the closed IR, #2231), total (all matching jobs, not the page length), offset, limit, hasMore and asOf.
  • Dashboard: JobDetailPage renders only the DTO.
    • Payment is green only for a gateway-classified paid, and recorded amounts are labeled "recorded".
    • A 404 shows "Job not found"; any other failure shows "unavailable" with a retry.
    • A failed refresh keeps the last read and marks it stale.
    • The page polls until the job reaches a terminal phase.
    • An "Inspect raw record" section keeps ids and raw statuses available.
    • useJobs now throws on an off-schema response instead of returning []. ApiError keeps the HTTP status.
  • JobsPage belongs to the shell lane (PX-3) and is not changed here.

Tests

  • @pcc/spec: 832/832 (8 new).
  • Gateway: 3017 passed, 6 skipped, 0 failed at the pushed head (36 new: 34 read-model plus 2 populator).
  • Dashboard: 238/238 (15 new), and tsc -b && vite build is clean.
  • Negative tests, one per rule:
    • completed with no record is not paid;
    • a job row that says settled is not paid;
    • funded plus completed is not_paid;
    • a refund is never paid;
    • an unknown money status is never paid;
    • a mock escrow is never paid;
    • sibling releases never pay this job;
    • ambiguous escrows or milestones choose nothing;
    • evidence is never verification;
    • a failed read is unavailable and leaks no internal detail;
    • missing timestamps stay null;
    • tenant isolation holds;
    • the dashboard never paints a non-paid payout green and never imports fixtures.
  • Mutation check: 16/16 red. Each rule above was broken on purpose and the suite failed each time.

Not in this PR (tracked)

Revision 2 (6c88956b..7e6071b6): answers the cross-family review (coord-watch #2477, astra: DO-NOT-SHIP)

Finding Fix Proof
P1-1: the resolver took the first unique lookup as proof of the job→escrow link Every recorded identifier (session escrow address, session CWM, job CWM) is looked up in full, with cardinality checked. Several rows for one identifier → ambiguous. Identifiers naming different escrows, an escrow contradicting a session identifier, or a session that records no escrow → conflicting. The job CWM links alone only when the job has no session. Reviewer's counterexample; address duplicates; session address vs job CWM; session address with no row; session CWM contradicting the escrow
P1-2: zero milestones meant a whole-escrow payout An escrow with no milestone rows → payout unknown. There is no whole-escrow fallback at all. Every escrow status with [] milestones → unknown
P1-3: a released milestone beat the escrow's own status reconcilePayout(milestone, escrow). Released + refunded/disputed/slashed/expired/unrecognized → unknown; unreleased or refunded + all-released → unknown. Both carry the settlement_records_conflict notice. Paid/refunded carry payoutConfirmation: "record_only". statusObservedAt: null because the record stores no status time. Full milestone × escrow table
P1-4: secondary badges bypassed the simulation guard Escrow and milestone statuses are neutral record text built from the DTO's classification and prefixed "Simulated:" on mock escrows. Only the payout badge can be green. Every canonical money status × simulated renders gray; a page-source ratchet forbids re-classification
P1-5: no object authorization (IDOR) authorizeJobRead runs before any axis is read, whatever TENANT_ENFORCE says. It allows a valid X-Admin-Key (constant-time compare, no dev bypass), the kernel operator, or the recorded buyer. Anonymous → 401; anyone else → 404. Anonymous, stranger, operator, buyer, admin and wrong admin key, each under both TENANT_ENFORCE settings
P2: polling stopped at the terminal phase Polling never stops: 15s while running, 60s after. Freshness is also age-based: a read older than two intervals shows as stale. Freshness tests; hook-source ratchet
Legacy lie The JobDetailDTO timeline no longer emits settled from a job row (mock settlement writes it). Populator test
Design #2510 No work phase uses a success or animated chip. View test

Authorization limits (stated in the code). The principal is the API key's operatorId; a proven identity is board N2 (gateway lane). The only record of the buyer is the negotiation session's userAgentId. Jobs submitted without a session record no buyer, so for those only the operator and admins can read.

Tests at 7e6071b6:

  • spec 832/832
  • gateway 3039 passed, 6 skipped, 0 failed
  • dashboard 248/248; tsc -b && vite build clean

Mutation check: 17/17 red. Each of the rules above was broken on purpose, and the suite failed every time. The initial run left two guards uncaught; I added tests that isolate them. Secret scan: 0 findings.

Revision 3 (7e6071b6..513b933a): carries #313's fix, and "paid" is no longer reachable from gateway records

  • Merge 7ec16196: brings in fix(ui-kit,dashboard,spec): one canonical money-status map - no refund/allocated/unknown as settled-green #313's fixed head a813c013 (steward #2688, genui #2743). The merge applied cleanly. feat: JobExecutionDTO read model; job detail stops reading mock data (PX-6) #353 no longer carries the old 506f437e code on its own.
  • 513b933a: adapts the payout axis to fix(ui-kit,dashboard,spec): one canonical money-status map - no refund/allocated/unknown as settled-green #313's map, following steward ruling #2490 (settlement tone comes only from authoritative money state).
    • Every bare word is now a non-green tone. Reconciling by tone would have turned a recorded release into not_paid, which is false. So the payout reads the gateway's escrow words exactly.
    • reported_released is a new payout state: this job's milestone record says released, and the escrow record does not contradict it. It carries payoutConfirmation: "record_only", is gray, and says it is not confirmed. On a V-next escrow, "released" can mean allocated, not paid out.
    • paid is reserved for an authoritative settlement read model: a V-next lifecycle or receipt that classifySettlementRecord shows as released. No gateway escrow record produces it. A test sweeps every word pair of the money map to check this. That answers the open review question ("can payout be paid when this job's money was not released?"): no.
    • A milestone saying completed is ambiguous, so it is unknown. Conflicts are unchanged.
  • Evidence:
    • Gateway 3042 passed / 6 skipped / 0 failed; spec 861; dashboard 249, tsc -b clean.
    • Mutation check on the new rule: 5/5 red.
    • Secret scan of the range: 0 findings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A2ZvsqsAb7jC7AC8Viisqn

LamaSu and others added 10 commits September 2, 2026 13:12
…o refund-as-payment)

statusClass() used a greedy substring regex that rendered any status containing
settl/releas/complet/done/paid/funded/success/approved/active as a green "settled"
pill. So a REFUND ("refunded" contains "funded"), an UNRELEASED/INCOMPLETE/UNSUCCESSFUL/
NOT_APPROVED/INACTIVE/UNDERFUNDED, and every *_ALLOCATED (decided, not final) rendered as
a completed PAYMENT. A refund shown as a payment is the read-route contract's forbidden-
asserter CRITICAL, live in the shipped on-ramp kit.

Replace it with an EXACT normalized-status map keyed off the sec-A conformance table
(V-next finalState/unitState, the 10-state machine) plus the legacy EscrowSummaryDTO enum:
- SETTLED_RELEASED / COMPLETED -> st-settled (operator distribution discharged)
- SETTLED_REFUNDED / REFUNDED  -> new non-green st-refunded ("payer refunded - operator NOT paid")
- RELEASE_ALLOCATED / REFUND_ALLOCATED / in-flight (0-5) / FUNDED / CREATED -> st-waiting
- unmapped / unknown -> new neutral st-unknown, NEVER green (fail closed)
No substring inference for money state. Add honest sec-A direction labels on the settlement
rail, and drop the dishonest rail fallback that inferred 'settled' from releasedCount
(contract rule 12: never key settlement off count/receipt existence).

Adversarial conformance test extracts the shipped <status-map v1> region verbatim (tests the
real bytes, not a copy) and asserts the sec-A table, the never-green invariant, normalization
(casing/whitespace/separators), generic-state tones, and that the old regex is gone. 7/7 green.

Refs: genui-read-route-contract sec-A + rules 1/12; genui conformance matrix v1.4.
Cross-family (sol/GPT-5.6) verdict: the one-line /refund/ guard is insufficient; this is the
exact-map it prescribed. gen-UI owns the fix (defect is in gen-UI's shipped kit + contract).
…d/dispute as green)

EscrowPage.tsx rendered the escrow status GlowBadge with `... : "green"` as the ternary
DEFAULT, so any status other than active/funded -- refunded, disputed, created, unknown --
rendered GREEN: a refunded or disputed escrow shown as a completed payment. Same false-green
money-display bug as the ui-kit statusClass fix, in a parallel formatter (the blast radius sol
predicted: "fixing one helper doesn't help if dashboards independently infer status").

Replace with an exact map defaulting to gray: completed -> green, disputed -> red,
active -> gold, everything else (funded/created/refunded/unknown) -> gray. Matches the
already-honest MilestoneTimeline statusColors + read-route contract rule 1.

Type-correct by inspection (all branches return valid GlowBadge colors: green|gold|red|gray);
a full dashboard typecheck/build is Spark-offload territory and was NOT run here.
Bring PR #313 current with master before extending it (dry-run merge-tree was
clean; no conflicts). No force-push: the PR history is preserved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…state table

Master had five competing escrow-status vocabularies (EscrowStatus,
Escrow.status, the dashboard DTO, the V-next UnitState, the context-pack
summary) and every surface re-inferred them; the shipped kit's substring regex
rendered a REFUND as a green completed payment (read-route contract rule 1).

MONEY_STATUS_MAP is the ONE exact table: normalized key -> semantic tone +
honest, direction-explicit label. Green ('settled') is reserved for the three
documented FINAL releases to the operator (SETTLED_RELEASED, COMPLETED,
RELEASED). Refunds are 'refunded' (final, operator NOT paid); allocated-not-
final states are 'waiting'; unknown fails closed. Bare SETTLED is deliberately
unmapped: SettlementResultDTO uses it for operator-paid but the V-next phase
vocabulary uses it for BOTH released and refunded.

Frozen at every level; compile-time coverage records make tsc fail if
EscrowStatus / Escrow.status gains a value with no entry. Browser-safe.
8 tests (sec-A table, never-green invariant, unknown fail-closed,
normalization, full vocabulary coverage, immutability).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…ifier

- <status-map v2> mirrors @pcc/spec MONEY_STATUS_MAP verbatim (the vanilla
  kit cannot import it) and now covers every documented escrow vocabulary, so
  canonical states like released/slashed/releasing/expired no longer render
  'unknown'.
- New moneyStatusClass for MONEY surfaces (the receipt window): money table
  only. An off-schema 'success'/'done'/'ok' on a money response is NOT a
  settlement state and never renders paid (it still tones a generic run/list
  surface via statusClass).
- Conformance test moved into CI: #313's node --test file lived under
  apps/dashboard/public/ (served publicly) and no CI job ran it. The new vitest
  file (a) extracts the shipped region verbatim and proves it equals the spec
  map key for key and agrees with classifyMoneyStatus over an adversarial
  battery, and (b) boots the WHOLE kit in jsdom and asserts the rendered receipt
  pill: refund / allocation / off-schema success / missing status (rule 12) are
  never settled-green. 19 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
EscrowPage's escrow badge had its own inline ternary (a sixth formatter). It
now calls moneyBadgeColor(), which takes the semantic tone from @pcc/spec
classifyMoneyStatus and only maps tone -> GlowBadge color: green is reserved
for a documented FINAL release; refunded / allocated / unknown are never green.
GlowBadge itself defaults to green, so money badges must pass an explicit color.

A compile-time record lists every value of the dashboard's EscrowStatus type
(tsc fails if the type gains one) and the test asserts each is a KNOWN state in
the canonical map, so no real escrow status renders 'unknown'. 3 tests;
dashboard tsc --noEmit clean; full dashboard suite 223/223.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
… phase table

Product pack section 7 / PX-6. A product surface needs to PROJECT a job, not
infer it. The DTO has four independent axes, each with an explicit enum, the
source it was read from, and that source's own timestamps:

- execution: what the executor reported. ONE exact table maps every job-row
  status PCC writes or documents (gateway JOB_STATUSES, KernelJobStatus,
  StepStatus, and the paid-job pipeline's active / evidence_stored /
  evidence_submitted / settled) to a phase. Anything else is `unknown`, never
  a success phase. `settled` in a job row is an execution fact only: mock
  settlement writes it with no money moving.
- evidence: received, counted, and fabricated-by-design events counted with
  the canonical isFabricated. Received is never verified.
- verification: outcome verification is `not_available` (no public verdict
  read exists); capture checks are counted as capture checks.
- settlement: the linked escrow record, classified by the canonical money map
  (#313 classifyMoneyStatus); payout is paid / refunded / not_paid /
  simulated / unknown, never derived from completion.

asOf is READ time (render-provenance contract, #2222/#2232). Browser-safe.
Compile-time coverage of KernelJobStatus and StepStatus; 8 tests (exact table,
fail-closed input including prototype keys, frozen table, terminal set).

agent: pcc-readmodels (c255d7dc)
…s not settled

PX-6: the live Jobs list drilled into a mock detail page, and the legacy job
DTO inferred payment from completion. This adds the product read model and
stops the inference.

- New GET /api/jobs/:jobId/execution -> JobExecutionDTO (@pcc/spec). One
  synchronous read pass over the store; each source (capability, kernel,
  evidence + events, capture verdicts, settlement) is read separately, and a
  failed read becomes `unavailable` with a generic error (details go to the
  log), never an empty list or a default. Same gate as GET /api/jobs/:jobId;
  under TENANT_ENFORCE a job of another tenant is a 404. cache-control:
  no-store.
- Settlement link follows the paid-job flow (negotiation session -> cwmId ->
  escrow), then the job's own cwmId, then the session's escrow address; more
  than one candidate is `ambiguous` and nothing is chosen. The payout reads the
  job's OWN milestone (by stepId). The whole-escrow status is used only when
  the escrow records no milestones; when milestones exist but none is this
  step, payout is unknown, so a sibling step's release never pays this job. A
  mock-settlement escrow (mock-escrow-*) is `simulated`, never paid.
- Legacy JobDetailDTO timeline: status `completed` now emits a `completed`
  event instead of `settled` (completed is not payment). A row that itself
  says `settled` is still echoed.
- JobDTO rows carry executionPhase (server-read, exact table), so no surface
  re-derives it from status.
- GET /api/jobs keeps { jobs } and adds collection-v1 `items` (the closed
  render IR list shape, #2231), total (ALL matching jobs, not the page
  length), offset/limit/hasMore and asOf (read time, #2222).

Tests: 34 in readmodels/job-execution.test.ts (builder, loader, route, list
envelope), including negatives: completed without a record is not paid; a
job row saying settled is not paid; funded+completed is not_paid; refund is
never paid; an unknown money status is never paid; a mock escrow is never
paid; sibling releases never pay this job; ambiguous links/milestones choose
nothing; evidence is never verification; failed reads are unavailable with
no internal detail; missing timestamps stay null; tenant isolation. Plus 2
populator tests. Full gateway suite: 3016 passed, 6 skipped, 0 failed.

agent: pcc-readmodels (c255d7dc)
…no mock data

PX-6, the continuity break: JobsPage (live) opened JobDetailPage, which read
only mockJobs/jobMeta/mockEscrows (emptied, so every real job said "Job not
found"), a hard-coded mockEvidence array, hard-coded DID / IPFS CID / Base and
Solana tx hashes, and a "Load demo trace" fixture.

JobDetailPage now renders GET /api/jobs/:jobId/execution only:
- four independent panels (Work, Payment, Evidence, Verification), each
  naming its source. "Work reported complete" is never payment; evidence is
  "received, not verified"; capture checks are explained as capture checks.
- Payment: only a gateway-classified `paid` is green (GlowBadge defaults to
  green, so every badge passes an explicit color); recorded amounts are
  labeled recorded, never paid; not-linked / ambiguous / unavailable read as
  what they are.
- 404 ("Job not found") is told apart from unavailable ("Job details are
  unavailable right now", with retry). A failed refresh keeps the last read
  and says it is stale. Polls every 15s until the phase is terminal.
- Notices from the DTO (job row says settled, fabricated evidence, simulated
  escrow, unknown status) are shown as-is.
- An "Inspect raw record" section keeps ids, raw statuses and the contract
  address available without forcing them into the main view.

Support: gateway.ts ApiError keeps the HTTP status (message unchanged);
getJobExecution; useJobExecution (no retry on 404); useJobs now throws on a
response without a jobs array instead of returning [] (absence is not
evidence); dto.ts JobDTO.executionPhase and the `completed` timeline event.
Presentation rules live in lib/job-execution-view.ts (exhaustive over the spec
unions). JobsPage is the shell lane's (PX-3) and is untouched here.

Tests: 10 view tests + 5 page-source tests (no fixture imports, no hard-coded
hashes/CIDs/DIDs, 404 vs unavailable, stale marker). Dashboard suite 238/238;
tsc -b && vite build clean.

agent: pcc-readmodels (c255d7dc)
…ate escrows

Two escrow records under the job's cwm must read `ambiguous` with no record and
payout unknown; picking the first would show one escrow's money state for a
job that may belong to the other. Mutation-checked: making the loader take the
first candidate turns this test red (16/16 mutations of the PX-6 rules are
caught by the suite).

agent: pcc-readmodels (c255d7dc)
LamaSu and others added 9 commits September 24, 2026 10:12
… confirmation

Response to the cross-family review of #353 (DO-NOT-SHIP, coord-watch #2477).
The types now carry what the gateway must prove before claiming any money:

- SettlementLink gains `conflicting`: the job's recorded identifiers point at
  different escrow records, or the record found contradicts one of them.
- SettlementAxis.linkMatches lists every identifier that points at the linked
  record; payoutBasis is only ever this job's own milestone (no whole-escrow
  fallback); payoutConfirmation `record_only` qualifies a paid/refunded claim
  that no chain receipt confirms.
- SettlementRecordView.statusObservedAt: null. The escrow record stores no
  time for its statuses, so a fresh asOf dates the read, not the settlement.
- PayoutState documents the reconciliation: paid needs this job's milestone
  to say released AND the escrow not to contradict it; any disagreement is
  unknown. no_milestones is unknown.
- New notices: settlement_records_conflict, settlement_link_conflict.

agent: pcc-readmodels (c255d7dc)
…ze the reader

Addresses the cross-family review of #353 (coord-watch #2477, astra), which
showed the read model could report a job as PAID with the wrong money.

P1-1 link: resolveSettlement now looks up EVERY recorded identifier in full
(the session's escrow address and CWM, the job's CWM), with row cardinality
checked (the address lookup no longer hides duplicates). One identifier
matching several escrows is ambiguous; identifiers matching different escrows
are conflicting; the one escrow found must match every identifier the session
recorded; a session that records no escrow cannot be linked through the job's
CWM. The job's CWM alone links only jobs with no negotiation session.
P1-2: an escrow with no milestone rows no longer yields a payout (several jobs
can share an escrow): unknown.
P1-3: the payout reads this job's milestone reconciled with the escrow's own
status (reconcilePayout); released + refunded/disputed/slashed/expired/
unrecognized escrow, and unreleased/refunded + an all-released escrow, are
conflicts -> unknown with a notice. paid/refunded are marked record_only.
P1-5: GET /api/jobs/:jobId/execution is object-authorized before any axis is
read (authorizeJobRead), whatever TENANT_ENFORCE says: a valid X-Admin-Key
(constant-time, no dev bypass when PCC_ADMIN_KEY is unset), the job's kernel
operator (kernel.operatorAddress), or its recorded buyer (the negotiation
session's userAgentId). Anonymous -> 401; anyone else -> 404 (no existence
oracle). Stated limits: the principal is the API key's operatorId (proven
identity is board N2, gateway); the buyer is only recorded as the session's
userAgentId.

Tests: 56 in readmodels/job-execution.test.ts, including the reviewer's
counterexample (unrelated completed escrow on the job's CWM), address
duplicates, session address vs job CWM naming different escrows, a session
address with no row, a session CWM contradicting the found escrow, zero
milestones for every escrow status, the full milestone x escrow table, and
the authorization matrix (anonymous, stranger, operator, buyer, admin, wrong
admin key) under both TENANT_ENFORCE settings. Mutation-checked: 17/17 red.

agent: pcc-readmodels (c255d7dc)
…b row

The job row can say the work finished; it cannot prove payment. Mock
settlement writes `settled` into the row with no money moving, so every "work
finished" row status (completed, settled, evidence_stored,
evidence_submitted, verified, awaiting_verification) now becomes a
`completed` timeline event and the legacy JobDetailDTO timeline never emits
`settled`. Payment lives on the escrow record (JobExecutionDTO settlement
axis). Raised by the #353 cross-family review as a remaining legacy lie.

agent: pcc-readmodels (c255d7dc)
…shing

Addresses the #353 cross-family review (P1-4, P2) and the design lane's note
(#2510).

- P1-4: the recorded escrow and milestone statuses are neutral record text
  (recordStatusBadge: always gray, labeled from the DTO's own classification,
  prefixed "Simulated:" on a mock escrow). The page no longer re-classifies raw
  money strings, so a simulated or conflicting record can never show a green
  "payment sent to operator" beside a payout that says otherwise.
- P2: the job keeps refreshing after the work finishes (60s instead of 15s),
  because finishing the work never makes the money final; a read older than
  two intervals is marked stale by age, as is a failed refresh.
- Design #2510: no work phase uses a success or animated-online chip; only
  "executing" (amber) and "failed" (red) carry color.
- Wording for the new states: link conflicts, records that disagree, no
  milestones, and "Not confirmed on chain" on a record-only paid claim. A 401
  says to sign in instead of "not found".

Tests: 18 view tests (every canonical money status x simulated renders
neutral; freshness; new wording) and 7 page-source tests (no raw money
re-classification, polling never turns off). Dashboard 248/248; tsc -b &&
vite build clean.

agent: pcc-readmodels (c255d7dc)
…id; route data surfaces by data

First part of the #313 review fixes (astra via coord-watch #2465, product-qa #2594, reviewer-bravo):
- Normalization rejects rather than erases: only a plain status string ([A-Za-z0-9 _-]) is
  classified. "RELEASED?", "❌RELEASED", "releaſed", ["RELEASED"] and objects whose toString
  returns a green word are now unknown. Spec and kit mirror are changed together.
- COMPLETED is no longer green. It ends many non-money DTOs (jobs, A2A, steps, batch claims
  where paid != completed), so it now reads "completed - settlement not confirmed".
- List rows and run status pick their table from the DATA: money unless the binding is a known
  non-money read and the row carries no money field. So an escrow row's "success" or "done" is
  never green, while a completed job still is. Generic surfaces check generic states first.
- The EscrowPage milestone badge now comes from the canonical map (no dead "fulfilled" green).

Tests: money-status 35/35 (+8); full spec 832/832; dashboard 223/223; the 7 gateway kit suites
95/95; tsc (spec, dashboard) clean. More #313 fixes follow (source-schema receipt adapter).

agent: pcc-genui (4df1e691)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…el; no bare-word green

Second part of the #313 review fixes: steward rulings #2490/#2688, astra (coord-watch #2465),
escrow #2580, product-qa #2594.

- Classify by SOURCE SCHEMA: classifySettlementRecord (spec) / settlementRecordClass (kit mirror):
  - V-next /lifecycle: unitState is an ordinal 1..9, pinned to VNextSettlementLib.sol's
    `enum UnitState`; 0 is a read error. finalState, isAllocated and isTerminal must agree, and
    a final state needs all three present.
  - V-next /receipt: finalState counts only when terminal with isAllocated true.
    `null` + allocated reads "decided, not yet paid out".
  - A legacy escrow record goes through the flat table on its status.
  - Anything else (a job, an A2A task) is "not a settlement record".
- The flat word table has NO green entry: bare SETTLED_RELEASED, RELEASED and COMPLETED are not
  settlement reads. The only green is a consistent V-next state 8. AWAITING_FUNDING is unknown.
  Labels are fixed per escrow F4-F6: "not final", "payer not yet refunded", "payout distribution
  discharged", "payees NOT paid".
- Receipt: state by schema; nothing invented (no default "USDC", "payer"/"payee" or
  "escrow-milestone"; missing values read "not reported").
- Run window: a read model by its schema; a full snapshot with no status reads unknown (an
  earlier green is never kept). List/run money rows that are read models use the schema.
- The kit tables are frozen. Compile-time coverage now constrains the real map (`satisfies`).
- EscrowPage: the "Total Locked" and selected-escrow panels no longer glow green.

Tests: money-status 18 + conformance 37 = 55. They cover wire fixtures for states 0-9, every
cross-check disagreement, missing corroboration, receipt shapes, job/A2A records, the kit==spec
adapter over the battery, the ordinal table re-read from the Solidity enum, frozen tables,
no-invention, run null snapshot, and money rows. Full spec 852/852; dashboard 223/223 (after a
spec build); gateway kit suites 95/95; tsc spec + dashboard clean.

agent: pcc-genui (4df1e691)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…rrency

Pins the no-invention rule for the currency line (the earlier test used a receipt with no
amount, so the currency branch never ran; mutation M8 survived). M8 is now red.

agent: pcc-genui (4df1e691)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…els-job-execution

Brings PX-1's fixed money-status map into PX-6 (steward #2688, genui #2743):
bare status words are never green, 'completed' is never paid, and settlement
tone comes only from classifySettlementRecord.

The merge applies cleanly. Under the new map a milestone record's 'released' is
no longer a settled tone, so the next commit adapts the payout axis (5
settlement-axis tests fail at this merge commit alone).

agent: pcc-readmodels (c255d7dc)
…_released, never paid

#313's fixed map (merged in the previous commit) makes every bare status word a
non-green tone, following steward ruling #2490: settlement tone comes only from
authoritative money state. A milestone record's "released" is now a waiting
tone, so reconciling by tone would have reported a recorded release as
"not_paid": a false statement.

The payout now reads the gateway's escrow words exactly (their source schema is
the escrow_milestones and escrows tables):
- a released milestone (released, settled_released) that the escrow record does
  not contradict is the new payout state reported_released, with
  payoutConfirmation record_only. It is gray and says it is not confirmed. On a
  V-next escrow "released" can mean the outcome was allocated, not paid out.
- "paid" is reserved for an authoritative settlement read model (a V-next
  lifecycle or receipt that classifySettlementRecord shows as released). The
  gateway's escrow records never produce it; a test sweeps every word pair of
  the money map to prove that.
- a milestone saying "completed" is ambiguous (completion is not release):
  unknown.
- conflicts are unchanged: released vs a refunded, disputed or slashed escrow,
  and refunded or unreleased vs an escrow saying everything was released, are
  unknown.

This also answers coord-watch's open question on #353 (can payout be "paid" when
this job's money was not released?): no gateway record can make it "paid" now.

Gateway 3042 passed / 6 skipped / 0 failed; spec 861; dashboard 249, tsc -b
clean. Mutation check on the new rule: 5/5 killed.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 24, 2026
…sign #3013)

A job row can literally say "settled" with nothing paid (#353's notice
job_row_reports_settled), so the Run card could read "Status: settled", and a
job list badge or a status metric likewise. Colour was already neutral; the
text was not.

Any value read from a field named `status` (status, job.status, kernel.status)
whose word is a money state now carries PCC's fixed qualifier "- reported by
the record, not confirmed by a settlement read" (the #313 wording), in every
sink: metric (bindScalar), run card (bindSchemaCard), list badge and list meta
(bindListRows). The money-state word list is narrower than the prose CLAIM_RE
("verified"/"approved" are not money states); lookalikes, zero-width, fullwidth
and camel/snake/kebab joins are folded first. The kit is rebuilt (check:ir-kit).

Tests: 4 new, including the shipped kit end to end (review344 19/19).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
…ocated/phase semantics

#313's classifier assumed isAllocated was true for every allocated-or-final state (6-9).
The read routes say otherwise: gateway unit-state-mapper isAllocatedState is true for 6/7
ONLY ("outcome decided, money NOT fully moved"), so a settled /lifecycle or /receipt body
carries isAllocated:false. A genuinely settled unit therefore rendered "settlement fields
disagree" and was never shown as settled -- fail-closed, but wrong, and the hand-written
test fixtures encoded the same assumption (one even pinned the real settled receipt as
unknown).

Now, in the spec classifier and the kit mirror alike:
- lifecycle: allocated = state 6 or 7; `phase` must match VNEXT_PHASE (the mapper's
  PHASE_BY_STATE) when present, and a final state needs finalState, isAllocated,
  isTerminal AND phase;
- receipt (finalState, phase, isAllocated): final only with isAllocated:false and
  phase:"settled" (both present); isTerminal, if present, must agree; "allocated" and
  "in flight" cross-check phase too.

A new gateway suite takes its fixtures from the routes themselves (Fastify inject over a
fake reader) for states 1-9 and classifies every body with the spec AND the shipped kit:
only state 8 is green, from either route, and each tampered field is unknown.

Tests: money-status 18 + conformance 39; spec 854/854; dashboard 223/223; new gateway
suite 3/3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
LamaSu and others added 2 commits September 24, 2026 15:52
…lay sum

The receipt window fell back to economics.amount and formatted it with fmtUsd. On the
real /receipt route that field is a raw integer in the token's BASE units (read-surface
contract rule 14) and the route sends no tokenDecimals, so a 1 USDC settlement
("1000000") would have rendered as 1,000,000.00.

Now economics.amount becomes a display amount only with the record's own tokenDecimals
(exact string arithmetic, no float); otherwise it is shown as "N base units (decimals
not reported)", with no invented currency. A malformed value is "amount not reported".
Top-level amount/totalAmount (legacy escrow records, already display units) are unchanged.

Tests: 3 new (conformance 42/42) using the route's exact EconomicsRecord shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
Genui #3113 / #3151. 7061730 classifies settlement reads by the routes'
own isAllocated / phase semantics (isAllocated is true for states 6/7
only, so a settled unit no longer reads as "fields disagree"), and
de3a973 stops the ui-kit receipt window formatting economics.amount
(base units) as a sum. No conflicts. JobExecutionDTO shows no
economics.amount: its milestone.amount and escrowTotal.amount are the
escrow record's decimal strings, labelled as recorded.

agent: pcc-readmodels (c255d7dc)
… it (escrow ruling #3163)

Escrow ruled that master's settlement routes are the target and asked gateway to ADD the
staticcall-authoritative unitState to /receipt (additive), so a receipt consumer can tell
6 (release decided) from 7 (refund decided); genui keys that direction off unitState,
never off finalState.

A receipt with unitState enters the unitState branch, which required isTerminal for a
final state, and /receipt carries no isTerminal, so settled receipts would have
classified "incomplete" the day the field lands. A final state now needs unitState,
finalState, isAllocated and phase (every present field still cross-checked, isTerminal
included when present). Spec and kit alike.

Tests: the route-derived suite classifies each state's real /receipt plus the lifecycle's
unitState (states 1-9: same tones as /lifecycle, green only at 8, 6 vs 7 named) and four
tampers of it (4/4); money-status 60/60. Requiring isTerminal again turns it red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7daTDp5o3mAwxYNYyRCVy
Genui #3217: per escrow ruling #3163 the gateway will add unitState to
/receipt; classifySettlementRecord accepts that shape and names state 6
vs 7 from unitState. Today's bodies classify as before. No conflicts.

agent: pcc-readmodels (c255d7dc)
… unwritten tenant column

Found by scout-alpha's evidence map: no writer sets
evidence_bundles.tenant_id (the kernel path, PUT /complete, the operator
relay and the setup test job all leave it null), so under TENANT_ENFORCE
the loader's tenant-filtered findByJob matched no rows and the evidence
axis said state "none" for a job that has evidence. The route already
refuses a job outside the caller's tenant before any axis is read, so
the job's evidence is read by job id alone.

Test: a tenant-a job with one bundle and one event, written the way
production writes them, read by a tenant-a operator under TENANT_ENFORCE
counts 1 bundle and 1 event (on the previous loader: state "none").

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 24, 2026
#353 now carries #313 @8f946499 (settlement-read classification by the
routes' isAllocated/phase semantics; base-unit receipts; unitState on
/receipt) and scopes JobExecutionDTO's evidence through the job instead
of the never-written evidence_bundles.tenant_id. The one adaptation:
loadLegacySettlement stops passing the removed `tenant` load option (it
still refuses a job outside the caller's tenant before any read).

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 24, 2026
Brings #313 @8f946499 and #353's evidence fix (read through the job, not
the never-written evidence_bundles.tenant_id). No conflicts; the gated
execution route drops its now-unused tenant variable, since gateJobRead
already refuses a job outside the caller's tenant.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 25, 2026
Brings #313 @8f946499 and #353's evidence fix (JobExecutionDTO reads a
job's evidence through the job, not the never-written
evidence_bundles.tenant_id). No conflicts.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 25, 2026
Brings #313 @8f946499 and #353's evidence fix through #389. No conflicts.

agent: pcc-readmodels (c255d7dc)
…stone is not this job's

Cross-family review r3 of #353 (23-px6-353-r3, astra, DO-NOT-SHIP).

P1-5, authorization:
- The operator and buyer checks now compare only a wallet proven by a SIWE signature:
  req.provenWallet, which WP-A's API gate (#326) sets for a SIWE session or an API key
  minted from one.
- An operatorId or email is never used, since self-service provisioning lets anyone
  claim one (the verdict's "single weakest link").
- Identity is checked BEFORE the job is read (precheckJobRead): no credential is 401, and
  a credential without a proven wallet is 403 identity_unverified. So no refusal depends
  on whether the job exists; anonymous callers used to get 404 or 401 by existence.
- A proven non-party gets the same 404 as a missing job.
- Recorded operators and buyers must be addresses; a label or an email never matches.
- Before WP-A merges, no caller has a proven wallet, so only an admin reads: this fails
  closed.

P1-1, attribution:
- A milestone records no job, so one milestone for this job's step is this job's only
  when no other job could claim it.
- Claimants are jobs with the escrow's CWM and the same step, plus jobs whose
  negotiation session names the escrow (countStepClaimants).
- More than one claimant is milestoneMatch shared_by_jobs: no milestone attributed,
  payout unknown, notice milestone_shared_by_jobs.
- Different steps on one CWM stay exact.

P1-3 and exact words:
- reconcilePayout decides by exact word sets only. The canonical map is used only to
  recognize a word, and a test checks that every word it reads is known to the map.
- An unrecognized status now says so (settlement_status_unrecognized; it used to give
  unknown with no notice).
- The DTO names why a linked payout is unknown (payoutUnknownReason: records_conflict,
  status_unrecognized, status_ambiguous, milestone_shared, no_single_milestone).
- No branch returns paid.

Tests: gateway job-execution suite 71, and the full gateway suite 3058/6/0 with tsc clean;
spec 865. Mutation check: 14 gateway mutants for these fixes, all killed.

agent: pcc-readmodels (c255d7dc)
… is never colored

Cross-family review r3 of #353, section 5.

- A 401, 403 or 404 on the LATEST read replaces the page, cached data included
  (jobPageState). Before, the page checked only when it had no data, so a signed-out or
  no-longer-authorized caller kept seeing the money under a stale warning. 403
  identity_unverified gets its own text: sign in with a wallet.
- Freshness is computed once per page. A stale read (failed refresh, or too old) never
  colors the payout (payoutBadge): the color is a claim about now.
- The query cache is cleared whenever the signed-in identity changes (auth-store
  onIdentityChange: a key, wallet or SIWE session signed in, changed or signed out), so
  the next identity never sees the previous one's reads. A 403 is final, like 401 and
  404 (no retry).
- The new notices and the payout's unknown reasons have plain wording.

Tests: dashboard 258 (13 files), including the auth-store identity test; tsc -b clean.
Mutation check: 6 dashboard mutants for these fixes, all killed.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
#353 now requires a proven wallet for job reads, checked before the job is read. It no
longer attributes a milestone another job could claim, and names why a linked payout is
unknown. #382's legacy settlement reads project the same settlement axis.

Conflict in readmodels/job-execution.ts: #382's job-row conflict (a job row saying
settled over a milestone record that is not released) becomes payoutUnknownReason
"job_row_conflict", which drives the same settlement_row_conflict notice. The spec union
gains that reason.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
…nknown

After #353's review-r3 fixes, the legacy settlement claim (GET /api/settlement/:jobId,
GET /api/jobs/:jobId/settlement) carries payoutUnknownReason and the two new money notices
(settlement_status_unrecognized, milestone_shared_by_jobs). The dashboard names a job-row
conflict as one, and a test pins the reason.

Tests: gateway 3081/6/0, dashboard 258, spec 865; tsc clean.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
…#403

#353's review-r3 fixes change the predicate this family's gate is built on, so the
merge adapts the gate. The merged tree does not build without it.

- gateJobRead: identity first (precheckJobRead). No credential is 401 and a credential
  without a PROVEN wallet (WP-A's req.provenWallet, SIWE) is 403 identity_unverified,
  both before the job row is read. Then the job, TENANT_ENFORCE, and
  authorizeJobRead(job, provenWallet): kernel operator or recorded buyer, compared as
  addresses. An operatorId or email is never trusted as an identity.
- refuseJobRead and the two legacy settlement routes answer 401 and 403 with the shared
  JOB_READ_REFUSAL bodies, the same for every job id.
- Conflict in routes/jobs.ts: the execution route keeps #403's gate call.
- Tests: the stand-in gate sets a proven wallet for a wallet principal
  (helpers/job-read-party provenWalletFor), the F3 buyer is a wallet, and every route in
  the family pins the new 403 for a key that claims the operator's or buyer's id without
  proof, identical for a missing job.

Effect: until WP-A (#326) merges, no caller has a proven wallet, so the whole job read
family is admin-only (fail closed). After it, email-provisioned and legacy keys cannot
read job records. That is the operator's call (decision posted with the #353 triage).

Tests: gateway 3131/6/0, dashboard 258; tsc clean. Gate mutation check: 5/5 killed.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
…ntity rule) into #441

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
…d id is 403

After #403 took #353's review-r3 identity rule, the job read gate requires a proven
wallet (WP-A's req.provenWallet). The test's stand-in gate sets one for a wallet
principal. A key that only claims the operator's id, without proof, is pinned as 403
identity_unverified.

Tests: gateway 3144/6/0, dashboard 258; tsc clean.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
Conflict in readmodels/job-execution.ts: #389's buildSettlementAxis now returns
buildSettlement's axis directly. The axis carries payoutUnknownReason, and the old
recordsConflict side channel is gone.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
…wallet

#353's review r3 (P1-5) applies here too: /api/operator/work and /api/operator/income
scoped the caller's kernels by the API key's operatorId, which self-service provisioning
lets anyone claim. So a key claiming a public operatorAddress read that operator's work
and income.

- The operator is now the caller's PROVEN wallet (SIWE: WP-A's req.provenWallet, via
  jobReadCallerOf).
- No credential is 401. A credential without a proven wallet (an email, a self-declared
  id) is 403 identity_unverified.
- Kernels match only as addresses, in any letter case: a SIWE session address is
  checksummed.

Tests:
- a claimed id and an email are 403;
- a key claiming kernel-nyc's operator but proven as another wallet sees none of its work;
- a kernel recorded under a checksummed address belongs to its lowercase proven wallet.
Gateway 3090/6/0. Mutation check: 3/3 killed.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
LamaSu added a commit that referenced this pull request Sep 30, 2026
…d gate (F1)

Astra round 1 on #382 (a6bcfb7), CRITICAL; reproduced first.
GET /api/jobs/:jobId/settlement and GET /api/settlement/:jobId read the job
before any identity check and applied only the optional tenant filter. An
unrelated or anonymous caller read another job's settlement, evidence
metadata, quote and escrow details. The new tests failed at a6bcfb7:
anonymous, a claimed id, a stranger's proven wallet and a wrong X-Admin-Key
all got 200 on both routes.

loadLegacySettlement now applies the same identity-first object
authorization as /execution (#353's precheckJobRead and authorizeJobRead):
- no credential: 401;
- a credential without a proven wallet: 403 identity_unverified. Both come
  before the job is read, for every job id alike.
- then the tenant check;
- then only X-Admin-Key, or a proven wallet that is the job's kernel
  operator or recorded buyer, reads it. Anyone else gets each route's own
  404, byte-identical to a missing job's.
- a failed authorization read: 503.

The integration tests now read as the seeded kernel operator's proven
wallet.

Also astra's LOW: the route comments no longer say a recorded release makes
a job settled (it is reported_released, settled: false).

agent: pcc-readmodels (c255d7dc)
@LamaSu
LamaSu marked this pull request as ready for review September 30, 2026 19:18
@LamaSu
LamaSu merged commit 48b6f75 into master Sep 30, 2026
5 checks passed
LamaSu added a commit that referenced this pull request Sep 30, 2026
…job-read-family-auth

#382's round-1 fixes come forward:
- milestones[].forThisJob from the attributed milestone, plus association;
- the corrected route comments;
- the job-party test helper. It was already byte-identical here.

Conflicts resolved to this branch's family gate: gateJobRead and its
unauthenticated, identity_unverified, not_found and unavailable kinds
replace #382's inline precheck and authorize. Both implement #353's rule,
and the gate is the shared one. The legacy-settlement integration tests
keep #382's explicit stand-in gate (identity only from headers), so its
401 and 403 tests hold. Their reads name the kernel-nyc operator
explicitly.

agent: pcc-readmodels (c255d7dc)
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