Skip to content

feat(readmodels): EvidenceProvenanceDTO, GET /api/jobs/:jobId/evidence/provenance (PX-7) - #441

Draft
LamaSu wants to merge 25 commits into
fix/job-read-family-authfrom
feat/readmodels-evidence-provenance
Draft

LamaSu wants to merge 25 commits into
fix/job-read-family-authfrom
feat/readmodels-evidence-provenance

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner

PX-7: EvidenceProvenanceDTO (GET /api/jobs/:jobId/evidence/provenance)

This is the charter's EvidenceSummary/ProvenanceDTO: what the gateway can truthfully say about a job's evidence, for ordinary users, with raw inspect pointers.

  • The vocabulary was confirmed, with corrections, by the evidence lane (#3346; returns/pcc-evidence-work/readmodels-evidence-summary-answers.md).
  • The facts it rests on were mapped read-only first (returns/pcc-readmodels-work/evidence-facts-map-20260924.md, 41 facts with file:line).

Stack: on #403 (fix/job-read-family-auth @ 7395657), whose job read gate this route uses. CI suites run only on PRs based on master, so the counts below are from local runs.

What the DTO says, and what it never says

Field Meaning
integrity Recomputed on read. event_bundle_hash is evidence integrity: every event hash reproduces from {type, timestamp, source, payload}, and the bundle hash from the sorted event hashes. This is the one LO-EV model /settle recomputes. gateway_envelope is only the gateway's storage integrity (the envelope PUT /complete hashes), never evidence integrity. no_model_reproduces is not proof of tampering (a device or relay hash has no model here). not_recomputable means there are no events.
signature Signer and algorithm as stored, checked: false. No stored bundle's signature has ever been checked.
tierCoverage Whether the recorded non-fabricated event types include what the claimed tier requires (DEFAULT_TIER_REQUIREMENTS). Self-reported, not a verification. Fabricated events never count (evidence #3346).
events Count, distinct types, fabricated, gatewayAuthored (written by the gateway, not a device), and first and last timestamps.
archive not_recorded: no archive CID is stored for any bundle.
verification no_verdict_recorded: the gateway stores no verifier or oracle verdict, so it never says "verified".
inspect METHOD+path of the raw envelope and events.

An unreadable store is unavailable with null counts, never none.

Access: the admin, the job's kernel operator or its buyer, through #403's gate. Anyone else gets the missing-job 404; an anonymous caller gets 401. no-store.

Also fixed (legacy truth)

Bundle rows carry no events, so EvidenceSummaryDTO.eventCount was always 0 on GET /api/jobs/:jobId/evidence, GET /api/compliance/evidence/:bundleId and JobDetailDTO.evidenceBundles. The facades now attach each bundle's events. verified there stays false: no column exists, and nothing records a verdict.

Not in this PR

Evidence

  • gateway pnpm test: 3125 passed / 6 skipped / 0 failed (192 files).
  • spec: 866.
  • dashboard: 249.
  • tsc clean.
  • 13 tests, including a real PUT /complete bundle that reproduces as gateway_envelope (so the recomputation matches what the route writes) and the legacy counts.
  • Mutation check 12/12 killed:
    • bundle hash accepted without event hashes;
    • envelope reported as evidence integrity;
    • fabricated events counted;
    • no events read as a match;
    • signature reported as checked;
    • any tier accepted;
    • unreadable store read as none;
    • both legacy fixes reverted;
    • order reversed;
    • gateway-authored miscounted;
    • the route's gate skipped.
  • The test stops the in-process evidence store in afterAll, as server.ts's onClose does. Without that, a real /complete run could abort the process at exit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A2ZvsqsAb7jC7AC8Viisqn

pcc.evidence-provenance/v1 is the charter's EvidenceSummary/ProvenanceDTO:
what the gateway can truthfully say about a job's evidence, for ordinary
users, with raw inspect pointers. The vocabulary was confirmed by the
evidence lane (#3346).
- integrity: event_bundle_hash is evidence integrity (the one LO-EV
  model /settle recomputes). gateway_envelope is only the gateway's
  storage integrity. no_model_reproduces is not proof of tampering.
- signature: checked is false (no stored bundle signature is checked).
- tierCoverage: recorded non-fabricated event types only;
  self-reported.
- archive: not_recorded.
- verification: no_verdict_recorded (no verdict is stored).

agent: pcc-readmodels (c255d7dc)
…vidence counts that are real

- readmodels/evidence-provenance.ts: loads each bundle of the job with
  its events and builds the DTO.
  - Integrity is recomputed on read: every event hash plus the bundle
    hash (event_bundle_hash), otherwise the /complete envelope
    (gateway_envelope).
  - Tier coverage counts non-fabricated events against
    DEFAULT_TIER_REQUIREMENTS.
  - Bundles are listed newest first; an unreadable store is
    unavailable, never none.
- routes/evidence-provenance.ts: behind the job read family's gate
  (#403). The admin, the kernel operator or the buyer can read it;
  anyone else gets the missing-job 404, and an anonymous caller 401.
  cache-control: no-store.
- Legacy truth (evidence facts map, item B): bundle rows carry no
  events, so EvidenceSummaryDTO.eventCount was always 0 on
  GET /api/jobs/:jobId/evidence, /api/compliance/evidence/:bundleId and
  JobDetailDTO.evidenceBundles. The facades now attach each bundle's
  events.

Tests: 13, including a real PUT /complete bundle that reproduces as
gateway_envelope (so the recomputation matches what the route writes)
and the legacy counts. Mutation check 12/12 killed. Gateway 3125/6/0.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 29, 2026
PUT /api/jobs/:jobId/complete writes the gateway's own execution_completed
and the caller's evidenceEvents, of any type, under source.deviceId
"gateway". No device reported them. Until now evidenceLevelOf read that
stamp as a device: a gateway completion counted as device_reported, and it
named "gateway" as an executing device, so a printer inspecting its own
output could read as inspected_output.

GATEWAY_STAMPED_DEVICE_ID is not a device attribution, so those events prove
no level and name no executor. deriveContradictions is unchanged (the oracle
mirrors it, J4): types decide there, and a contradiction can only refuse.

Found while checking readmodels' #441 against the evidence vocabulary; #441
will take evidenceLevel from evidenceLevelOfBundle.

spec 826/826, tsc clean. Mutant (drop the clause) fails both new tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ntity rule) into #441

agent: pcc-readmodels (c255d7dc)
…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)
…e placeholder signer, keeps envelope integrity apart

The evidence lane, which owns the contract, checked #441 against #3346 (#3680):

F1. Tier coverage counts only events a DEVICE recorded: neither fabricated nor
    gateway-stamped (source.deviceId "gateway"). PUT /complete stamps its own
    execution_completed AND the caller's body events of any type that way, so a /complete
    bundle claiming tier 2 read "covers" with no device event at all. The lane's probe is
    now a test.
F2. The gateway's placeholder signature (the zero-address signer /complete writes for
    events it synthesized, or value "gateway-auto-sign") is no signature: signer and
    algorithm null.
F3. Integrity is a discriminated union. recomputed_match means only the LO-EV model
    (event_bundle_hash); an envelope-only match is storage_envelope_match (model
    gateway_envelope). A surface reading only `state` never sees "recomputed" for a bundle
    /settle would refuse.

Nits:
- firstAt/lastAt are ordered by parsed time and shown as recorded; a string sort put
  "...00.500Z" before "...00Z".
- A model that throws on a stored value (a circular value today; an integer beyond 2^53-1
  after #359) is caught per bundle and reads no_model_reproduces. It no longer fails the
  whole read.

Tests: evidence-provenance 18; gateway 3149/6/0; spec 866; dashboard 258; tsc clean.
Mutation check: 7/7 killed.

agent: pcc-readmodels (c255d7dc)
…no signature (PX-7)

Evidence #4088 (the contract owner); reproduced first with a failing test at
5a94723.

POST /api/operator/evidence stores a bundle that has no device signature as
{signer: <kernelId>, algorithm: "sha256", value: "operator-relay-auto"}, and
the provenance read showed it as signed by that kernel.

signatureOf now treats a signature as a placeholder when any of these holds:
- its value is in the gateway's own PLACEHOLDER_SIGNATURE_VALUES
  (services/device-evidence-settlement.ts);
- its signer is the zero address;
- its value carries the emitter's test_sig_ marker.

These are the non-signature checks isDeviceSignedSignature applies. It does
not require ed25519, so a secp256k1 kernel signature is still shown.

agent: pcc-readmodels (c255d7dc)
…ence store to start

PUT /complete starts the in-process evidence store (Helia by default). Alone,
the file takes about 4 s. In the full gateway suite on a loaded machine these
tests exceeded vitest's 5 s default: 2 timed out at 5a94723, and 1 after the
placeholder fix. The block now allows 30 s per test and for the teardown that
stops the store. The wait is for startup, not for an answer the tests could
miss.

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

The previous commit guarded the gateway's own settlement flow (PUT
/complete, resume-settlement, the keeper). An audit of every release
entry found two more that ignored a refund decision; both are now
refused. Each was reproduced first, with a test that failed on 830e4ff.

1. The raw chain routes: POST /api/escrow/chain/:address/{evidence,
   attestation,release}/:idx, on the EAS (V2/V3) and V1 paths. They are
   operator/admin-scoped, but they read no refund state, so an operator
   could push a refund-pending escrow to a release. 24 cases failed:
   "expected 200 to be 409". They now answer 409 escrow_refunded and send
   nothing. They also fail closed with 503 when the escrow registry cannot
   be read. Disputes stay open, because V2 refunds only through
   resolveDispute; approve-release only encodes calldata for the payer's
   own wallet, so it is unchanged.
2. SettlementService.releaseMilestone: POST /api/settlement/release and
   the automatic release after evidence. It released a refund-pending
   escrow and then set its job "settled", the N79 negative itself (4
   cases failed). It now refuses when the job's own escrow, or the escrow
   it is asked to release (the named address or the default, in any
   letter case), was given back. Its activity no longer retries that
   refusal.

The lookup is shared: givenBackEscrow and escrowByContractAddress in
escrow-refund.ts.

Also:
- The route tests assert the escrow rows, which are what this change
  writes. They no longer go through GET /api/jobs/:id/settlement: that
  read is readmodels', and #441 makes it authenticated.
- Two import lines move one line away from #459's and #441's insertion
  points, so both merge cleanly with this branch.

All ten new mutants are killed: each route guard alone, the status check,
fail-open on registry errors, the service guard, the job and address
halves of the lookup, the checksum form and the activity's retry. The
gateway suite passes: 3037 passed, 6 skipped. tsc is clean.
…rd tier coverage

M3: tierCoverageOf() only excluded fabricated and gateway-stamped
("gateway") events, so an event with source: null (or no deviceId)
was neither fabricated nor gateway-stamped and still counted toward
"DEVICE recorded" tier coverage, contradicting the DTO contract's
"event types that a DEVICE reported" description.

Reproduced with a new test at 5f3c86e: a tier-1 LO-EV bundle with
the three required types, every event source: null, asserted
tierCoverage.state is not "covers" -> failed ("expected 'covers' not
to be 'covers'") before the fix.

Added deviceReported(): true only when source.deviceId is a
non-empty string other than the gateway's "gateway" stamp.
tierCoverageOf() now filters on !isFabricated && deviceReported
instead of !gatewayStamped. Updated the builder's and spec's doc
comments to document that source-less events are excluded the same
way as gateway-stamped ones. No existing fixture relied on
source-less events counting, so no fixtures changed.

Files: packages/gateway/src/readmodels/evidence-provenance.ts,
packages/spec/src/readmodels/evidence-provenance.ts,
packages/gateway/src/__tests__/readmodels/evidence-provenance.test.ts

agent: implementer-bravo for pcc-readmodels (c255d7dc)
…ature

M4: routes/setup.ts's deviceless-kernel branch (:765,:783) writes
kernelSignature: { signer: "self-attest", algorithm: "none", value:
"self-attested by kernel ... at ..." } as a gateway-created,
non-cryptographic placeholder. signatureOf() only suppressed the
zero-address signer, the two PLACEHOLDER_SIGNATURE_VALUES, and
test_sig_-prefixed values, so this shape passed through and the DTO
reported signer:"self-attest", algorithm:"none", checked:false as if
it were a real signature.

Reproduced with a new test at 5f3c86e: a bundle carrying that exact
stored shape, asserted signature equals {signer: null, algorithm:
null, checked: false} -> failed ("expected { signer: 'self-attest',
...(2) } to deeply equal { signer: null, algorithm: null, ...(1) }")
before the fix.

signatureOf() now also treats signer === "self-attest" OR algorithm
=== "none" as a placeholder (either alone is sufficient — no real
signature ever declares algorithm "none"). Added SELF_ATTEST_SIGNER /
NO_ALGORITHM constants and updated the builder's and spec's doc
comments. The same test asserts a real secp256k1 and a real ed25519
signature are still shown as stored.

Files: packages/gateway/src/readmodels/evidence-provenance.ts,
packages/spec/src/readmodels/evidence-provenance.ts,
packages/gateway/src/__tests__/readmodels/evidence-provenance.test.ts

agent: implementer-bravo for pcc-readmodels (c255d7dc)
…string

M5: the DTO contract promises bundles are "Newest first", but the
builder's sort compared raw storedAt strings
(a.storedAt < b.storedAt ? ...). Equivalent ISO-8601 offsets can
reverse actual time order under a string comparison: a "+02:00"
offset string can sort ahead of a "Z" string that is chronologically
later.

Reproduced with two new tests at 5f3c86e: (1) stored times
"2026-09-28T12:00:00+02:00" (10:00 UTC) and "2026-09-28T10:30:00Z"
(30 min later) -> the code put the older "+02:00" bundle first,
failing "expected [ 'b-off', 'b-utc' ] to deeply equal [ 'b-utc',
'b-off' ]"; (2) an unparseable storedAt sorted first instead of
last, failing similarly.

Added compareBundlesNewestFirst(): sorts by Date.parse(storedAt)
descending; a storedAt that fails to parse sorts after every bundle
that parses; equal (or equally unparseable) times break the tie on
bundleId ascending, for a deterministic order. storedAt is still
displayed exactly as recorded — only the sort key is parsed. The one
other multi-bundle ordering test (unambiguous UTC timestamps) is
unaffected.

Files: packages/gateway/src/readmodels/evidence-provenance.ts,
packages/gateway/src/__tests__/readmodels/evidence-provenance.test.ts

agent: implementer-bravo for pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Sep 30, 2026
…eath it (N79 round 2)

astra's pack 116b on #462 @38cf4116: DO-NOT-SHIP, with three HIGH
findings and one MEDIUM. Each was reproduced first with a failing test at
38cf411 (n79-refund-vs-settlement.test.ts):
- F1 HIGH: a repeated failure write while /complete is in flight
  refunded ("expected 'refunded' to be 'skipped'");
- F2 HIGH: cancelling one job refunded an escrow another job shares;
- F3 HIGH: a refund landed while SettlementService.releaseMilestone
  awaited the chain ("expected 'refund_pending' to be 'skipped'");
- F4 MEDIUM: a completion that failed after a failure write left the
  failed job's escrow funded.
Two more were reproduced the same way:
- the keeper, which sweeps a findAll() snapshot across awaits, drove an
  escrow given back mid-sweep (astra's Q2 note);
- the raw chain release route let a refund land while it awaited the
  chain (the F3 class, found by extending the audit).

The fix is one mechanism: DURABLE SETTLEMENT OWNERSHIP on the escrow row.
- A settlement takes its escrow with a compare-and-set from
  funded/active to `completing` (already in @pcc/spec's vocabulary),
  synchronously at its claim: /complete (same stretch as the job claim),
  resume-settlement, SettlementService.releaseMilestone (before the
  chain await) and the raw release route (by contract address).
- A refund never lands on a `completing` escrow, however often the
  job's mutable status is rewritten (F1, F3, raw route).
- A settlement that gives up BEFORE recording evidence hands the escrow
  back (prior status). If the job ended meanwhile, the escrow goes to the
  payer then, in one transaction (F4). After evidence exists, the
  settlement keeps it, since resume-settlement continues it.
- A release that went through records its milestone `released`, and the
  escrow `completed` once every milestone is.
- The refund gives back a whole escrow only if no other job references
  it; a shared escrow is skipped as `escrow_shared` (F2).
- The keeper re-reads the row right before each drive.

Tests: the six reproductions, plus the gaps astra named:
- a failure on the LAST write rolls back every milestone and the status;
- a failure after evidence keeps ownership;
- resume takes a legacy row (the write repeated, as in F1);
- a partial release hands the escrow back;
- failed releases (service and raw route) hand it back.
All 40 mutants (both rounds) are killed. The gateway suite passes: 3053
passed, 6 skipped (six threads; the Spark is at load 37, where two /complete
tests time out at 5 s and pass alone). tsc is clean. It merges cleanly with
#326, #385, #403, #424, #441, #445, #450, #451 and #459.
#403 answered its round-1 review in 4988a19. Among its changes, the
compliance bundle routes now run gateJobRecordRead on the bundle's job:
identity first, then the job read rule. That closes this PR's r1b
CRITICAL: GET /api/compliance/evidence/:bundleId returned eventCount
for another tenant's bundle with no object authorization. #441 stacks
on #403. The merge is clean.

agent: pcc-readmodels (c255d7dc)
…and a blank deviceId names no device (PX-7)

Cross-family review r1b of #441 (5f3c86e), MEDIUM 2, plus the owner's
follow-up to MEDIUM 3. Both reproduced first with failing tests.

MEDIUM 2: an LO-EV match binds only each event's {type, timestamp,
source, payload} and the sorted event hashes. Yet the DTO showed the
row's kernel, claimed tier, time and signature, and the event ids,
beside recomputed_match. Changing kernelId, assuranceTier and an event
id left the match intact (the reviewer's reproduction). Every integrity
result now carries:
- covers: the stored fields it vouches for;
- notCovered: every other stored field.
event_bundle_hash covers event.type, event.timestamp, event.source,
event.payload, event.hash and bundle.bundleHash. Its notCovered lists
the bundle's id, jobId, stepId, kernelId, assuranceTier, createdAt and
kernelSignature, plus event.id. The /complete envelope covers all 14
stored fields (still storage integrity only). A non-match covers none.
The spec exports EVIDENCE_STORED_FIELDS and INTEGRITY_COVERAGE.

MEDIUM 3 follow-up: 811441e requires a device source, but a deviceId
of spaces still counted as a device, and " gateway " escaped the
gateway's own stamp. deviceReported now trims the id first.

agent: pcc-readmodels (c255d7dc)
…Count (#441 r1b CRITICAL)

Cross-family review r1b of #441, CRITICAL. GET
/api/compliance/evidence/:bundleId returned the bundle's eventCount
(this PR loads the events for it) with no object authorization. The
merge of #403 (b6b688f) gates the route with gateJobRecordRead, on
the bundle's job, identity first. The surface test now also asserts
that the job's party reads eventCount and a stranger's answer does
not contain it.

agent: pcc-readmodels (c255d7dc)
#441 is stacked on #403. This brings in #403's round-3 fixes. The one
conflict was CLAUDE.md's read-access paragraph. It is resolved to
#403's new text, keeping #441's /evidence/provenance route.

tsc exits 0, and the readmodels tests pass (6 files, 216 tests).

agent: pcc-readmodels (c255d7dc)
… read only evidence the caller may read (PX-7 r3)

Cross-family review r2 of #441 (rm-px7-441-r2-e411a9b3, DO-NOT-SHIP),
CRITICAL: the capability compliance report named each recent bundle's
job and hash to anyone, and GET /api/evidence/:hash served the whole
canonical envelope (its events' sources and payloads) to anyone who had
the hash. px7-r3-evidence-bypass.test.ts reproduced it at d2ffa1d
(#441 @e411a9b3 plus the #403 r3 merge): 5 failed of 5.

- GET /api/capabilities/:capabilityId/compliance checks identity first:
  401 without a credential, 403 without a proven wallet. Then the facade
  computes the whole report only from evidence the caller may read
  (ComplianceReportAccess): all of the kernel's for an admin without a
  tenant, or the kernel's operator; anyone else gets the bundles of its
  own jobs. evidenceScope and bundlesConsidered say which. Events,
  capture verdicts, drift and the evidence list all come from those
  bundles only.
- GET /api/evidence/:hash serves the envelope only to:
  - the settlement oracle's verifier read key (X-Verifier-Key, compared
    constant-time with PCC_VERIFIER_READ_KEY; at least 32 characters;
    unset grants nothing; it is not an identity elsewhere);
  - an admin;
  - a party to the bundle's job (gateJobRecordRead, identity first,
    then the bundle lookup).
  Anyone else falls through to the job-id form, so it gets exactly what
  an unknown hash gets.
- compliance-routes.test.ts mocks the gate open; its mock gains the
  scope helpers, and the delegation test expects the access rule.
- The dashboard's ComplianceReportDTO mirror gets the two fields. The
  docs (CLAUDE.md, docs/AGENT_INTEGRATION.md) give the rule and the new
  env var.

Until the oracle sends X-Verifier-Key and the gateway has
PCC_VERIFIER_READ_KEY, the oracle's API-key fetch gets 403 and fails
closed. Provisioning that key is the operator's decision.

agent: pcc-readmodels (c255d7dc)
#441 is stacked on #403. This brings in #403's rounds 4 and 5.
- gateJobRecordRead now takes the record itself (job, kernel, tenant).
  The hash route's lookup returns the bundle row instead of its jobId.
- The one conflict was CLAUDE.md's read-access paragraph. It is
  resolved to #403's text, plus #441's /evidence/provenance route and
  its envelope-by-hash sentence.
- That sentence, here and in docs/AGENT_INTEGRATION.md, now states that
  the route is behind the API gate, so the oracle sends X-Verifier-Key
  together with its ordinary Bearer credential (astra r3 on #441, LOW).

tsc exits 0, and the readmodels and compliance-routes tests pass
(10 files, 258 tests).

agent: pcc-readmodels (c255d7dc)
… r4)

Cross-family review r3 of #441 (rm-px7-441-r3-01ed0dac), CRITICAL: the
new X-Verifier-Key could reach Sentry's request context. Reproduced
with the installed SDK (@sentry/node 10.45.0). Its request-data
integration copies every request header into an error event, and
removes only cookies and IP headers on request (core
integrations/requestdata.js:5-9, 80-94). The SDK's sensitive-header
list applies to span attributes only. sentry-scrub.test.ts "baseline"
sends an error under a request carrying the verifier key through the
plain SDK with a capture transport: the key is in the sent event.

- sentry.ts: sentryOptions() sets sendDefaultPii: false, plus
  beforeSend and beforeSendTransaction = scrubSentryEvent. The scrub
  redacts every header whose name matches the SDK's own
  sensitive-name list (auth, token, secret, session, key, cookie, ...),
  the cookies, and credential-named query parameters in both
  query_string and the URL. initSentry uses sentryOptions().
- sentry-scrub.test.ts: with the gateway's options, none of the
  verifier key, admin key, bearer key, session cookie or ?token= value
  is sent, and the user-agent and path still are.

agent: pcc-readmodels (c255d7dc)
Cross-family review r3 of #441, MEDIUM: the negative tests were
narrower than the attack list. px7-r4-evidence-matrix.test.ts pins,
against the real routes:
- every accepted hash form: bare, 0x, sha256:, upper and mixed case,
  space-wrapped. Each resolves for an admin, and the 0x-stored bundle
  resolves from its sha256: form;
- a stranger's forbidden hash answers exactly as an unknown one does
  (status, content-type, body with the hash masked), with the same
  findByHash and job reads and no event read;
- the verifier header: the name in any case works; a value in another
  case, space-padded, or repeated is no credential; a configured key
  of 31 characters grants nothing, even to an exact header;
- under TENANT_ENFORCE, tenant A's admin gets the unknown-hash answer
  for tenant B's bundle, and tenant B's admin reads it;
- a bundle whose job row does not exist is an unknown hash;
- reports over mixed bundles: the buyer sees only its job's bundle
  (bundlesConsidered 1), tenant A's admin only tenant A's, and the
  operator all of them (scope "all").

No source change: every case already held at the head.

agent: pcc-readmodels (c255d7dc)
Cross-family review r4 of #441 (rm-px7-441-r4-3c6783bc), CRITICAL 1 and MEDIUM 2.
At 3c6783b a failing request's query credentials reached the Fastify request
log (req.url), the gateway's own Sentry capture (extra.url, sent whenever the
SDK's Fastify hook skips the error) and every transaction's span attributes
(http.query, http.url, http.target, url.path). Signature headers
(payment-signature, x-hmac-signature, lob-signature) reached error events,
transaction request headers and span attributes. A write request's query
credential was stored in the audit log.

observability-redact.ts is one redaction for the whole record:
- headers by allowlist, in every shape;
- span header attributes by the same allowlist;
- credential-named keys;
- credential parameters in every string;
- JSON bodies, parsed and redacted.

Where it runs:
- Sentry: beforeSend, beforeSendTransaction, beforeSendSpan and
  beforeBreadcrumb.
- The Fastify logger: a request serializer that logs the redacted URL and no
  headers, plus a streamWrite hook that redacts every line.
- At the source: the error handler's extra.url and the audit log's url.

Tests:
- observability-redaction.test.ts boots the real gateway with its own Sentry
  and logger options (a capture transport and a buffered stream). It also pins
  the verifier key through the real API gate: alone it gets 401, and with a
  Bearer credential 200.
- observability-redact.test.ts pins each mechanism.
- sentry-scrub.test.ts adds transactions, breadcrumbs, signature headers and a
  request body.

agent: pcc-readmodels (c255d7dc)
Cross-family review r4 of #441 (rm-px7-441-r4-3c6783bc), MEDIUM 3. A report
over readable and unreadable bundles equals, in every derived field, the report
over a dataset that holds only the readable bundles. Counting the unreadable
ones would change recentEvidence, bundlesConsidered, tierCompliance,
captureVerification and assuranceScore.

The test passes at 3c6783b too: the report already used only readable
bundles. It closes the coverage gap.

agent: pcc-readmodels (c255d7dc)
…e of the request's URL

The steward's guidance on #441 round 4 (post #5182): the request log's req
serializer should drop every query value, not only credential-named ones,
because a name list can miss one (an OAuth code, a one-time link's token).
The URL in the error report's extra.url now drops them the same way. The
generic redaction of every string and the audit URL keep the
credential-name rule.

A mutation check confirms the new test catches a serializer that redacts by
name only: with the old serializer, the code parameter's value was logged.

agent: pcc-readmodels (c255d7dc)
…content (N107)

Cross-family review r5 of #441 (rm-px7-441-r5-179c4777), CRITICAL 2 and 3,
split out as N107 (PR steward #5254). Both sinks are live on master too, and
these files are byte-identical on master 7d688ce.

- The security monitor sent PostHog the raw request URL, query parameter,
  cookie or body as an attack event's attackPayload. Every event's
  fingerprint also carried the raw Referer, and a path that kept its
  fragment. Now:
  - an attack event carries a summary: its type, its source, the path with
    no query or fragment, and the content's length (attackLength);
  - the Referer is reduced to its URL with no query or fragment;
  - the monitor's own log lines carry the path instead of the URL.
- The payment gate kept each paid request's raw URL in recentPayments, and
  GET /api/x402/stats showed the list to any caller. It now keeps the path
  with no query or fragment, and only an admin (X-Admin-Key) sees the list.
  Everyone else gets the counts. No client reads this route.

Reproduced first: n107-telemetry-sinks.test.ts failed 5 of 5 at 179c477,
with every marker sent under an arbitrary name. Each fix was
mutation-checked with file copies: undoing any one of the five fails its
test.

agent: pcc-readmodels (c255d7dc)
…e's name (#441 r6)

Cross-family review r5 of #441 (rm-px7-441-r5-179c4777), CRITICAL 1, under
the PR steward's rule for round 6 (#5254): no credential-name list is
load-bearing.

Reproduced first at 179c477: observability-r6.test.ts failed 5 of 5. The
test sends a marker under arbitrary names (zq1, x_custom, code) in the
query, fragment, headers, form body and JSON body, through the real
gateway.

- Strings, in every sink: every URL-like token keeps its path and its
  parameter names and drops every query and fragment value. A
  form-encoded string drops every value. withoutQueryValues also covers a
  URL with a fragment and no query.
- Bodies: Sentry no longer collects request bodies, cookies or query
  strings. The HTTP integration reads no incoming body, and the
  request-data integration attaches none. As a second line, the redaction
  drops request.data, request.cookies and any "body" field whole.
- The audit record keeps the URL with no query or fragment value.
- PostHog: every event's and every person's properties go through the same
  redaction at the posthog-service boundary, whoever calls.
- The credential-name rule stays only as a second line, for prose.
- Two r4/r5 tests pinned a body's non-secret field being kept; they now pin
  the body being dropped whole. The r5 production-path test now composes
  the gateway's own Sentry integrations.

Mutation checks with file copies: undoing any one of seven parts fails a
test. The parts are the URL tokens, the form rule, the body drop, the
at-source Sentry setting, the PostHog boundary, the audit URL and the
fragment-only URL.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Oct 3, 2026
…or log or payment row keeps a caller's value (N107 r2)

Cross-family review r1 of #514 (rm-n107-514-r1-081b0c49, DO-NOT-SHIP). Each finding was reproduced at
081b0c4 by n107-r1.test.ts before any fix: 3 of its tests failed.

- CRITICAL 1: every fingerprint sent PostHog raw header values (User-Agent, Accept-Language,
  Content-Type, cf-*, X-Forwarded-For, x-railway-edge), and the Referer kept its userinfo.
  buildFingerprint is now a closed schema of derived values:
    - the client is a keyed hash (per-process key), which PostHog's distinct id uses too;
    - the User-Agent is a class (browser, http_library, scanner and so on);
    - the language is its primary subtag;
    - the Referer is a kind (direct, same_origin, cross_origin, invalid);
    - the content type is an allowlisted media type, and the country an ISO code;
    - the edge is an enum, and the forwarding chain a hop count;
    - the path is the matched route's pattern.
- CRITICAL 2: the monitor's logs carried User-Agent text (HONEYPOT's ua, and bot reasons). Bot
  reasons now name the signal only. The log lines carry the validated address, the route and the
  UA class.
- MEDIUM 3: MPP storage, both protocols' 402 paths, and exact counter deltas had no test.
  n107-mpp.test.ts (a scripted MPP charge handler: a 402, a success, a failed charge) and the
  legacy case in n107-r1.test.ts now pin them. Both passed at 081b0c4 too: the behaviour was
  right, and only the tests were missing.
- recentPayments now keeps the route's pattern, not pathOnly(req.url). An encoded separator
  (%3F, %23) or a value in a path segment can no longer reach the stats. This is the same class
  as cross-family review r6 of #441, MEDIUM 3.

Mutation checks on file copies, each restored and checked with cmp, fail the tests:
- a raw User-Agent in the fingerprint: 2 tests fail;
- the User-Agent back in the HONEYPOT line: 1 fails;
- the raw URL in a legacy payment row: 1 fails.

agent: pcc-readmodels (c255d7dc)
…ity redaction (#441 r7)

This fixes cross-family review r6 of #441 (rm-px7-441-r6-57233724), MEDIUM 3: the value-free rule
found a query or fragment only by a literal ? or #. So "/cb%3Fzq1%3D<value>" kept its value in
strings sent to Sentry, in the request log and in the audit row's URL.

Reproduced at 5723372 by observability-r7.test.ts before the fix: 2 of 2 failed, the verdict's
redactCredentials case and the request log plus audit row through the real createGateway.

observability-redact.ts now reads %3F, %23, %26 and %3D (any case) as ?, #, & and = wherever a rule
looks for separators: in withoutQueryValues (the request log's serializer, extra.url and the audit
URL), in each URL-like token of any string, and in the whole-string form rule. The output shows them
decoded. A mutation check confirms the tests catch it: with 5723372's redactor, both new tests fail.

Under the PR steward's strict ruling (#5315, #5319), the redactors stay as defence in depth. The
closed producer schema (the monitor's fingerprint, PostHog's distinct id, allowlisted header values,
generic property values) is built in #514, and #441 rebases onto it. Those r6 findings were reproduced
at 5723372 and are not changed here.

agent: pcc-readmodels (c255d7dc)

This branch has not been deployed

No deployments
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