Skip to content

feat(attestation): end-to-end ingest pipeline (Plan 4) - #6

Merged
messagesgoel-blip merged 17 commits into
mainfrom
feat/attestation-ingest
Jul 28, 2026
Merged

feat(attestation): end-to-end ingest pipeline (Plan 4)#6
messagesgoel-blip merged 17 commits into
mainfrom
feat/attestation-ingest

Conversation

@messagesgoel-blip

@messagesgoel-blip messagesgoel-blip commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Plan 4 — Attestation Ingest End-to-End

Replaces the placeholder attestation flow with the full ingest pipeline per spec §4.5. The control plane now receives a raw JWS token and performs all verification steps in-process.

New files

File Purpose Tests
jws.ts JWS pre-parser — decodes header/payload for iss/kid/iat without verification 6
schemaValidator.ts Validates facts against behavioral@0 (legacy) and behavioral@1 (native) 8
trustEngineClient.ts In-process Ed25519 JWS verification (mirrors gRPC VerifyAttestation) 8

Ingest flow

  1. Pre-parse JWS → extract iss, kid, iat
  2. Resolve candidate keys from principal_keys (valid at iat, not revoked)
    • kid present → select that key directly
    • kid absent → try all active candidate keys (legacy fallback)
  3. Synchronous verification via crypto.verify (Ed25519)
  4. Schema validation against versioned facts schema
  5. trust_delta validation (range -100..100, sign matches attestation_type)
  6. Dedup on token_digest (sha256 of JWS)
  7. Lazy subject creation if principal doesn't exist
  8. Transactional storage with composite FK to principal_keys

Updated

  • attestationService.ts — full ingest flow (was accepting pre-verified payload)
  • routes/attestations.ts — accepts { token } only
  • principalRepository.ts — added getIssuer, getKeyByKid, getActiveKeysAt

No attestation sync event written (score recomputation emits edge-visible events).

Tests

  • 22 TypeScript tests (JWS pre-parser, schema validator, trust engine client)
  • All Go tests still pass

Summary by CodeRabbit

  • New Features
    • Updated attestation submission to take only a JWS token and run an end-to-end ingest pipeline (signature verification, issuer/key resolution at issuance time, schema/versioned facts validation, deduplication, transactional save).
    • Added stricter JWS pre-parsing and deterministic facts hashing.
    • Improved attestation access control: enforce required write/read scopes and apply staff-aware tenant visibility filtering.
  • Bug Fixes
    • More precise rejection for malformed/expired tokens, schema violations, and conflicting submissions.
  • Tests
    • Expanded unit coverage for JWS parsing, schema validation, verification logic, and canonical hashing.
  • Documentation
    • Added an end-to-end attestation ingest plan.

Replace the placeholder attestation flow with the full ingest pipeline
per spec §4.5. The control plane now receives a raw JWS token and
performs all steps in-process:

1. Pre-parse unverified JWS for iss/kid/iat (jws.ts)
2. Resolve candidate keys from principal_keys (valid at iat, not revoked)
   - kid present: select that key directly
   - kid absent: try all active candidate keys (legacy fallback)
3. Synchronous Ed25519 signature verification (trustEngineClient.ts)
   - In-process via Node crypto.verify, mirrors gRPC VerifyAttestation
4. Schema validation (schemaValidator.ts)
   - behavioral@0 (legacy allowlisted) + behavioral@1 (native)
5. trust_delta range + sign validation (negative for negative_incident)
6. Dedup on token_digest (sha256 of JWS)
7. Lazy subject principal creation
8. Transactional storage with composite FK to principal_keys

New files:
- jws.ts / jws.test.ts — JWS pre-parser (6 tests)
- schemaValidator.ts / schemaValidator.test.ts — attestation schema validation
- trustEngineClient.ts / trustEngineClient.test.ts — in-process JWS verification (8 tests)
- principalRepository.ts — added getIssuer, getKeyByKid, getActiveKeysAt

Updated:
- attestationService.ts — full ingest flow replacing pre-verified payload
- routes/attestations.ts — accepts { token } only, no pre-verified payload

No attestation sync event written (score recomputation emits edge-visible events).
@cursor

cursor Bot commented Jul 27, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

The attestation flow now accepts raw JWS tokens, resolves issuer keys, verifies signatures, validates schemas and trust constraints, maps subjects, enforces tenant visibility, deduplicates tokens, and persists records transactionally. Supporting parsing, authorization, repository, migration, documentation, and test coverage were added.

Changes

Attestation ingest

Layer / File(s) Summary
JWS, facts, and schema contracts
control-plane/src/domains/attestation/jws.ts, schemaValidator.ts, canonicalize.ts, legacyConfig.ts, control-plane/src/domains/attestation/*.test.ts
Adds compact JWS parsing, canonical facts hashing, legacy schema configuration, and versioned schema validation with coverage for valid and invalid inputs.
JWS signature verification
control-plane/src/grpc/trustEngineClient.ts, trustEngineClient.test.ts
Adds Ed25519 verification, algorithm and expiration checks, nested and legacy claim mapping, and verification failure tests.
Issuer keys and subject mapping
control-plane/src/domains/principal/principalRepository.ts, control-plane/migrations/007_legacy_mapping/migration.sql
Adds issuer/key lookups, time-valid key selection, legacy DID principal creation, conflict handling, and a unique legacy DID index.
Attestation submission processing
control-plane/src/domains/attestation/attestationService.ts, attestationRepository.ts
Resolves and verifies claims, applies schema and trust constraints, derives digests, maps subjects, deduplicates submissions, enforces observation pairing, and persists attestations transactionally.
Tenant authorization and visibility
control-plane/src/middleware/*, control-plane/src/lib/tenantFilter.ts, control-plane/migrations/008_platform_role/migration.sql
Adds platform-role handling, tenant-aware scope checks, caller tenant resolution, and visibility filtering for attestation listings.
Raw-token route wiring
control-plane/src/routes/attestations.ts, control-plane/src/routes/attestations.test.ts, docs/superpowers/plans/*
Validates raw token requests, enforces route scopes, forwards tenant context, and documents the end-to-end ingest plan.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AttestationsRoute
  participant AttestationService
  participant PrincipalRepository
  participant TrustEngineClient
  participant AttestationRepository
  Client->>AttestationsRoute: POST /submit with token
  AttestationsRoute->>AttestationService: submitAttestation(jwsToken)
  AttestationService->>PrincipalRepository: load issuer and candidate keys
  AttestationService->>TrustEngineClient: verify JWS signature
  TrustEngineClient-->>AttestationService: verified claims
  AttestationService->>AttestationRepository: deduplicate and persist attestation
  AttestationRepository-->>Client: attestation response
Loading

Possibly related PRs

  • Numeracode/verilink#4: Touches the earlier attestation service and ingestion foundation refactored by this raw-token flow.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: an end-to-end attestation ingest pipeline.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/attestation-ingest

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Attestation: end-to-end JWS ingest with in-process verification

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Accept raw JWS tokens and perform full attestation ingest in the control plane.
• Resolve issuer keys at iat, verify Ed25519 signature, validate schema and trust_delta.
• Deduplicate on token_digest, lazily create subject principals, and store transactionally.
Diagram

sequenceDiagram
  participant Client
  participant Route as "POST /attestations/submit"
  participant Svc as "attestationService"
  participant JWS as "preParseJWS"
  participant PR as "principalRepo"
  participant TE as "trustEngineClient"
  participant SV as "schemaValidator"
  participant DB as "Postgres"
  Client->>Route: Submit { token }
  Route->>Svc: submitAttestation(jwsToken)
  Svc->>JWS: Decode iss/kid/iat (unverified)
  Svc->>PR: Load issuer + candidate keys
  PR->>DB: SELECT issuers/principal_keys
  Svc->>TE: verifyAttestation(token, keys)
  TE-->>Svc: VerifyResult (payload, verifiedKeyId)
  Svc->>SV: validateSchema(type, version, facts)
  Svc->>DB: INSERT attestations (dedup by token_digest)
  DB-->>Svc: Stored attestation row
  Svc-->>Route: Attestation
  Route-->>Client: 201 Created / 4xx / 409
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a standard JOSE library for JWS parsing/verification
  • ➕ Reduces risk of subtle base64url/JWS parsing bugs and claim-handling edge cases
  • ➕ Often provides hardened signature verification and algorithm whitelisting
  • ➕ Simplifies future support for additional algorithms/headers
  • ➖ Adds a dependency and potential bundle/runtime footprint
  • ➖ May require more configuration/typing work than minimal custom code
  • ➖ Might duplicate logic with existing trust-engine contract
2. Call trust-engine via gRPC instead of in-process verification
  • ➕ Single source of truth for verification behavior across services
  • ➕ Avoids maintaining two verification implementations
  • ➕ Can centralize observability/auditing for verification failures
  • ➖ Requires proto generation and client wiring (noted as a v1 complexity)
  • ➖ Introduces network/runtime dependencies and failure modes
  • ➖ Harder local dev/testing without a running trust-engine
3. Push schema validation into a shared, versioned schema package
  • ➕ Keeps schema rules consistent across control plane / other consumers
  • ➕ Easier to evolve behavioral@N without scattering logic
  • ➕ Enables stronger typing/codegen from JSON schema
  • ➖ More upfront work than the current targeted validator
  • ➖ Release/version management overhead
  • ➖ May be premature while schema is still stabilizing

Recommendation: The PR’s approach is reasonable for v1: keep verification synchronous and in-process to ship the full ingest pipeline quickly, with unit tests covering critical paths. The main follow-up to consider is swapping the custom JWS handling for a vetted JOSE library or migrating back to the trust-engine gRPC call once proto/client generation is in place, to reduce long-term security/maintenance risk.

Files changed (10) +778 / -66

Enhancement (6) +413 / -66
attestationService.tsReplace placeholder submit flow with full JWS ingest pipeline +109/-58

Replace placeholder submit flow with full JWS ingest pipeline

• Reworks submitAttestation to accept only a raw JWS token and perform pre-parse, issuer/key resolution at iat, Ed25519 signature verification, schema validation, trust_delta validation, dedup by token_digest, lazy subject creation, and transactional persistence. Maps verified payload fields into the stored attestation record and normalizes duplicate-token conflict handling.

control-plane/src/domains/attestation/attestationService.ts

jws.tsAdd unverified JWS pre-parser for iss/kid/iat extraction +65/-0

Add unverified JWS pre-parser for iss/kid/iat extraction

• Introduces preParseJWS to decode base64url header/payload without signature verification, with explicit validation of compact serialization structure and minimal header requirements (alg). Provides typed header/payload shapes for downstream extraction.

control-plane/src/domains/attestation/jws.ts

schemaValidator.tsAdd versioned facts schema validation (behavioral@0 and @1) +44/-0

Add versioned facts schema validation (behavioral@0 and @1)

• Implements validateSchema with explicit SchemaValidationError for unknown types/versions and structural validation of facts. behavioral@0 is permissive; behavioral@1 requires at least one key and only primitive values.

control-plane/src/domains/attestation/schemaValidator.ts

principalRepository.tsAdd issuer and key resolution helpers for ingest-time verification +38/-0

Add issuer and key resolution helpers for ingest-time verification

• Adds getIssuer to assert issuer eligibility, getKeyByKid for direct kid lookup (non-revoked), and getActiveKeysAt to fetch candidate keys valid at a specific iat window (non-revoked, valid_from/valid_until range).

control-plane/src/domains/principal/principalRepository.ts

trustEngineClient.tsImplement in-process Ed25519 JWS verification API mirroring trust-engine +148/-0

Implement in-process Ed25519 JWS verification API mirroring trust-engine

• Adds verifyAttestation that parses compact JWS, enforces EdDSA/Ed25519 algorithm allowlist, iterates over candidate Ed25519 public keys, and verifies signatures with Node crypto.verify. Normalizes legacy/new claim names into a single VerifyPayload shape returned on success.

control-plane/src/grpc/trustEngineClient.ts

attestations.tsChange submit endpoint to accept { token } only +9/-8

Change submit endpoint to accept { token } only

• Updates POST /submit to remove the caller-supplied verified payload and require a string token, returning BAD_REQUEST when missing. Delegates to attestationService.submitAttestation for the full ingest pipeline and continues to return 201 with the stored attestation.

control-plane/src/routes/attestations.ts

Tests (3) +278 / -0
jws.test.tsUnit tests for JWS pre-parser error handling and claim extraction +60/-0

Unit tests for JWS pre-parser error handling and claim extraction

• Adds tests covering successful parsing, iss/kid/iat extraction, and rejection of malformed tokens (wrong part count, invalid base64url, invalid JSON, missing alg).

control-plane/src/domains/attestation/jws.test.ts

schemaValidator.test.tsUnit tests for schema validation rules and failure modes +54/-0

Unit tests for schema validation rules and failure modes

• Covers behavioral@0 permissive behavior, behavioral@1 constraints (non-empty, no nested objects/arrays), and unknown type/version rejection.

control-plane/src/domains/attestation/schemaValidator.test.ts

trustEngineClient.test.tsUnit tests for Ed25519 verification across success and failure cases +164/-0

Unit tests for Ed25519 verification across success and failure cases

• Adds tests that generate Ed25519 keypairs, sign compact JWS tokens, and assert verification succeeds for matching keys and fails for wrong keys, no candidates, tampering, malformed/empty tokens, and unsupported alg headers.

control-plane/src/grpc/trustEngineClient.test.ts

Documentation (1) +87 / -0
2026-07-27-verilink-4-attestation-ingest.mdDocument Plan 4 attestation ingest end-to-end tasks and verification steps +87/-0

Document Plan 4 attestation ingest end-to-end tasks and verification steps

• Adds a Plan 4 spec document describing the intended end-to-end ingest flow, new modules, and verification checklist, aligning implementation steps with spec §4.5.

docs/superpowers/plans/2026-07-27-verilink-4-attestation-ingest.md

@qodo-code-review

qodo-code-review Bot commented Jul 27, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (2) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 4 rules

Grey Divider


Action required

1. kid skips key validity ✓ Resolved 🐞 Bug ⛨ Security
Description
When kid is present, submitAttestation() uses getKeyByKid() which only filters `revoked_at IS
NULL and ignores valid_from/valid_until at the token’s iat`, allowing verification with expired
or not-yet-valid keys.
Code

control-plane/src/domains/principal/principalRepository.ts[R163-168]

+export async function getKeyByKid(principalId: string, keyId: string): Promise<PrincipalKey | null> {
+  const { rows } = await pool.query(
+    `SELECT * FROM principal_keys
+     WHERE principal_id = $1 AND key_id = $2 AND revoked_at IS NULL`,
+    [principalId, keyId]
+  );
Evidence
getKeyByKid() omits validity predicates while getActiveKeysAt() includes them;
submitAttestation() uses the weaker lookup when kid is present.

control-plane/src/domains/attestation/attestationService.ts[63-73]
control-plane/src/domains/principal/principalRepository.ts[163-170]
control-plane/src/domains/principal/principalRepository.ts[172-182]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The `kid` fast-path bypasses time-window enforcement for key activation/expiration. This defeats key lifecycle controls whenever a token specifies a key id.

### Issue Context
The non-`kid` path correctly uses `getActiveKeysAt()` which enforces `valid_from/valid_until`, but the `kid` path does not.

### Fix Focus Areas
- control-plane/src/domains/attestation/attestationService.ts[63-73]
- control-plane/src/domains/principal/principalRepository.ts[163-183]

### Suggested fix
Choose one:
1) Change `getKeyByKid(principalId, keyId)` to accept `iat: Date` and add predicates:
  - `valid_from <= $3 AND (valid_until IS NULL OR valid_until >= $3)`
2) Keep the repository method as-is, but in `submitAttestation()` validate the returned key’s `valid_from/valid_until` against the parsed `iat` and reject if outside the window.

Also ensure the error returned is a 4xx (BAD_REQUEST) explaining the key is not active at `iat`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Facts parse causes 500 ✓ Resolved 🐞 Bug ☼ Reliability
Description
submitAttestation() JSON.parses vp.factsJson but only translates SchemaValidationError to 400; if
the signed JWS provides facts as a non-JSON string, JSON.parse throws and the global handler
returns a 500.
Code

control-plane/src/domains/attestation/attestationService.ts[R95-102]

+  try {
+    validateSchema(vp.attestationType, vp.schemaVersion, JSON.parse(vp.factsJson));
+  } catch (err: any) {
+    if (err instanceof SchemaValidationError) {
+      throw new AppError(CODES.BAD_REQUEST, `schema validation failed: ${err.message}`);
+    }
+    throw err;
+  }
Evidence
toPayload() passes through string facts as-is, so factsJson may be invalid JSON;
submitAttestation() parses it without catching SyntaxError, and the global response wrapper maps
non-AppError exceptions to INTERNAL (500).

control-plane/src/grpc/trustEngineClient.ts[71-85]
control-plane/src/domains/attestation/attestationService.ts[94-103]
control-plane/src/shared/http/responses.ts[33-37]
control-plane/src/shared/errors/AppError.ts[46-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`submitAttestation()` calls `JSON.parse(vp.factsJson)` during schema validation (and again for hashing). If `vp.factsJson` is not valid JSON, this throws a `SyntaxError` which is not converted to an `AppError`, resulting in a 500 response.

### Issue Context
`trustEngineClient.toPayload()` intentionally preserves `facts` verbatim when it is a string, so `factsJson` can be any attacker/issuer-provided string.

### Fix Focus Areas
- control-plane/src/domains/attestation/attestationService.ts[94-134]
- control-plane/src/grpc/trustEngineClient.ts[71-85]

### Suggested fix
- Parse `vp.factsJson` exactly once inside `submitAttestation()` in a `try/catch`.
- On JSON parse failure, throw `new AppError(CODES.BAD_REQUEST, 'facts must be valid JSON')` (or similar).
- Reuse the parsed `facts` object for both `validateSchema(...)` and `factsHash` computation to avoid double-parsing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Invalid iat not rejected 🐞 Bug ≡ Correctness
Description
submitAttestation() treats any truthy payload.iat as valid and constructs a Date without checking
finiteness/validity, so non-numeric iat values can propagate into key lookup SQL and cause
request/DB errors (and iat=0 is incorrectly treated as missing).
Code

control-plane/src/domains/attestation/attestationService.ts[R41-51]

+  const { header, payload } = preParsed;
+  const issuerId = payload.iss;
+  const kid = header.kid;
+  const iat = payload.iat ? new Date(payload.iat * 1000) : undefined;
+
+  if (!issuerId) {
+    throw new AppError(CODES.BAD_REQUEST, 'JWS missing iss claim');
+  }
+  if (!iat) {
+    throw new AppError(CODES.BAD_REQUEST, 'JWS missing iat claim');
+  }
Evidence
iat is constructed from an untyped JSON value with only a truthiness check, and then passed to
getActiveKeysAt() which uses it in SQL timestamp comparisons.

control-plane/src/domains/attestation/attestationService.ts[41-52]
control-plane/src/domains/principal/principalRepository.ts[172-182]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`iat` is derived via a truthiness check and multiplication, which allows invalid dates (e.g., string iat -> `NaN`) to pass the `if (!iat)` guard and be used in DB queries. Also, `iat=0` (Unix epoch) is rejected because it is falsy.

### Issue Context
`getActiveKeysAt()` uses the Date directly as a query parameter in timestamp comparisons.

### Fix Focus Areas
- control-plane/src/domains/attestation/attestationService.ts[41-52]
- control-plane/src/domains/principal/principalRepository.ts[172-182]

### Suggested fix
- Extract `iatRaw = payload.iat` and require `typeof iatRaw === 'number' && Number.isFinite(iatRaw)`.
- Build `iat = new Date(iatRaw * 1000)` and require `!Number.isNaN(iat.getTime())`.
- If invalid, throw `new AppError(CODES.BAD_REQUEST, 'JWS iat must be a unix timestamp (seconds)')`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. verifyAttestation() custom JWS verify 📘 Rule violation ⛨ Security
Description
The new verification code manually parses the compact JWS and uses low-level crypto.verify rather
than a JWS/JOSE-compliant verification library. This can lead to non-compliant or subtly incorrect
JWS handling compared to JOSE/JWS standards and violates the checklist requirement to use
JWS-compliant libraries for signatures.
Code

control-plane/src/grpc/trustEngineClient.ts[R48-59]

+function parseJws(token: string): DecodedJws {
+  const parts = token.split('.');
+  if (parts.length !== 3) {
+    throw new Error('invalid JWS: expected 3 compact parts');
+  }
+  const [headerB64, payloadB64, signatureB64] = parts;
+  const header = JSON.parse(base64urlDecode(headerB64).toString('utf8'));
+  const payload = JSON.parse(base64urlDecode(payloadB64).toString('utf8'));
+  const signature = base64urlDecode(signatureB64);
+  const signedInput = Buffer.from(headerB64 + '.' + payloadB64, 'utf8');
+  return { headerB64, payloadB64, signature, header, payload, signedInput };
+}
Evidence
PR Compliance ID 856634 requires signatures be handled using a JWS-compliant library rather than
bespoke parsing/verification. The cited code implements custom compact-JWS parsing and then performs
signature verification directly via Node's low-level crypto primitives.

Rule 856634: Use JWS (JSON Web Signature) for all cryptographic signatures
control-plane/src/grpc/trustEngineClient.ts[48-59]
control-plane/src/grpc/trustEngineClient.ts[127-133]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`trustEngineClient.verifyAttestation()` implements JWS parsing and signature verification manually (custom base64url decode + JSON parse + `crypto.verify`). The compliance requirement expects use of a JWS/JOSE-compliant library for producing/consuming signatures.

## Issue Context
The current implementation constructs `signedInput` from `headerB64.payloadB64` and verifies with `crypto.verify`, bypassing standard JOSE/JWS validation behavior (header validation rules, strict base64url parsing, etc.).

## Fix Focus Areas
- control-plane/src/grpc/trustEngineClient.ts[43-59]
- control-plane/src/grpc/trustEngineClient.ts[105-108]
- control-plane/src/grpc/trustEngineClient.ts[125-145]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
5. New TypeScript app logic 📘 Rule violation § Compliance
Description
This PR adds new application/source logic in TypeScript (.ts) under control-plane/src/,
violating the requirement that VeriLink repository source code be written in Go. This increases
compliance/audit risk by introducing a non-approved implementation language for core functionality.
Code

control-plane/src/domains/attestation/jws.ts[R1-6]

+// control-plane/src/domains/attestation/jws.ts
+export interface JWSHeader {
+  alg: string;
+  kid?: string;
+  typ?: string;
+}
Evidence
PR Compliance ID 856631 requires repository source code to be written in Go and disallows
introducing new non-Go application logic. The cited new/modified files add TypeScript implementation
for the attestation ingest pipeline and signature verification.

Rule 856631: All VeriLink repository source code must be written in Go
control-plane/src/domains/attestation/jws.ts[1-65]
control-plane/src/domains/attestation/schemaValidator.ts[1-44]
control-plane/src/grpc/trustEngineClient.ts[1-148]
control-plane/src/domains/attestation/attestationService.ts[1-163]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR introduces new `.ts` implementation files containing application logic, but the compliance checklist requires all repository source code to be written in Go.

## Issue Context
New TypeScript modules were added for attestation ingest (JWS parsing, schema validation, signature verification), which are not permitted under PR Compliance ID 856631.

## Fix Focus Areas
- control-plane/src/domains/attestation/jws.ts[1-65]
- control-plane/src/domains/attestation/schemaValidator.ts[1-44]
- control-plane/src/grpc/trustEngineClient.ts[1-148]
- control-plane/src/domains/attestation/attestationService.ts[1-163]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Missing sub claim check ✓ Resolved 🐞 Bug ≡ Correctness
Description
verifyAttestation() coerces a missing sub claim to an empty string, and submitAttestation() uses
it to look up/create a principal, potentially inserting an empty-id principal or triggering DB
errors instead of returning a 4xx.
Code

control-plane/src/domains/attestation/attestationService.ts[R125-129]

+  // 9. Lazy subject creation
+  let subject = await principalRepo.getPrincipal(verifiedSubjectId);
  if (!subject) {
-    await principalRepo.createPrincipal(verified.subjectId, 'agent');
+    await principalRepo.createPrincipal(verifiedSubjectId, 'agent');
  }
Evidence
verifyAttestation() produces subjectId as an empty string for missing sub, and
submitAttestation() uses verifiedSubjectId directly for principal creation without validation.

control-plane/src/grpc/trustEngineClient.ts[110-113]
control-plane/src/domains/attestation/attestationService.ts[90-93]
control-plane/src/domains/attestation/attestationService.ts[125-129]
control-plane/src/domains/principal/principalRepository.ts[27-39]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`subjectId` is not validated as required/non-empty before principal lookup/creation.

### Issue Context
`verifyAttestation()` currently sets `subjectId` to `''` when `sub` is missing.

### Fix Focus Areas
- control-plane/src/grpc/trustEngineClient.ts[110-113]
- control-plane/src/domains/attestation/attestationService.ts[90-93]
- control-plane/src/domains/attestation/attestationService.ts[125-129]

### Suggested fix
- Add a required-claim check after verification (or inside `verifyAttestation()`): if `!verifyResult.subjectId` (or it’s not a non-empty string), return/throw an invalid result.
- Convert this to `AppError(CODES.BAD_REQUEST, 'JWS missing sub claim')` before calling `getPrincipal/createPrincipal`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +1 to +6
// control-plane/src/domains/attestation/jws.ts
export interface JWSHeader {
alg: string;
kid?: string;
typ?: string;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. New typescript app logic 📘 Rule violation § Compliance

This PR adds new application/source logic in TypeScript (.ts) under control-plane/src/,
violating the requirement that VeriLink repository source code be written in Go. This increases
compliance/audit risk by introducing a non-approved implementation language for core functionality.
Agent Prompt
## Issue description
The PR introduces new `.ts` implementation files containing application logic, but the compliance checklist requires all repository source code to be written in Go.

## Issue Context
New TypeScript modules were added for attestation ingest (JWS parsing, schema validation, signature verification), which are not permitted under PR Compliance ID 856631.

## Fix Focus Areas
- control-plane/src/domains/attestation/jws.ts[1-65]
- control-plane/src/domains/attestation/schemaValidator.ts[1-44]
- control-plane/src/grpc/trustEngineClient.ts[1-148]
- control-plane/src/domains/attestation/attestationService.ts[1-163]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5c4ab3.

Comment on lines +48 to +59
function parseJws(token: string): DecodedJws {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('invalid JWS: expected 3 compact parts');
}
const [headerB64, payloadB64, signatureB64] = parts;
const header = JSON.parse(base64urlDecode(headerB64).toString('utf8'));
const payload = JSON.parse(base64urlDecode(payloadB64).toString('utf8'));
const signature = base64urlDecode(signatureB64);
const signedInput = Buffer.from(headerB64 + '.' + payloadB64, 'utf8');
return { headerB64, payloadB64, signature, header, payload, signedInput };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. verifyattestation() custom jws verify 📘 Rule violation ⛨ Security

The new verification code manually parses the compact JWS and uses low-level crypto.verify rather
than a JWS/JOSE-compliant verification library. This can lead to non-compliant or subtly incorrect
JWS handling compared to JOSE/JWS standards and violates the checklist requirement to use
JWS-compliant libraries for signatures.
Agent Prompt
## Issue description
`trustEngineClient.verifyAttestation()` implements JWS parsing and signature verification manually (custom base64url decode + JSON parse + `crypto.verify`). The compliance requirement expects use of a JWS/JOSE-compliant library for producing/consuming signatures.

## Issue Context
The current implementation constructs `signedInput` from `headerB64.payloadB64` and verifies with `crypto.verify`, bypassing standard JOSE/JWS validation behavior (header validation rules, strict base64url parsing, etc.).

## Fix Focus Areas
- control-plane/src/grpc/trustEngineClient.ts[43-59]
- control-plane/src/grpc/trustEngineClient.ts[105-108]
- control-plane/src/grpc/trustEngineClient.ts[125-145]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5c4ab3.

Comment thread control-plane/src/domains/attestation/attestationService.ts
Comment on lines +41 to +51
const { header, payload } = preParsed;
const issuerId = payload.iss;
const kid = header.kid;
const iat = payload.iat ? new Date(payload.iat * 1000) : undefined;

if (!issuerId) {
throw new AppError(CODES.BAD_REQUEST, 'JWS missing iss claim');
}
if (!iat) {
throw new AppError(CODES.BAD_REQUEST, 'JWS missing iat claim');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Invalid iat not rejected 🐞 Bug ≡ Correctness

submitAttestation() treats any truthy payload.iat as valid and constructs a Date without checking
finiteness/validity, so non-numeric iat values can propagate into key lookup SQL and cause
request/DB errors (and iat=0 is incorrectly treated as missing).
Agent Prompt
### Issue description
`iat` is derived via a truthiness check and multiplication, which allows invalid dates (e.g., string iat -> `NaN`) to pass the `if (!iat)` guard and be used in DB queries. Also, `iat=0` (Unix epoch) is rejected because it is falsy.

### Issue Context
`getActiveKeysAt()` uses the Date directly as a query parameter in timestamp comparisons.

### Fix Focus Areas
- control-plane/src/domains/attestation/attestationService.ts[41-52]
- control-plane/src/domains/principal/principalRepository.ts[172-182]

### Suggested fix
- Extract `iatRaw = payload.iat` and require `typeof iatRaw === 'number' && Number.isFinite(iatRaw)`.
- Build `iat = new Date(iatRaw * 1000)` and require `!Number.isNaN(iat.getTime())`.
- If invalid, throw `new AppError(CODES.BAD_REQUEST, 'JWS iat must be a unix timestamp (seconds)')`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5c4ab3.

Comment thread control-plane/src/domains/principal/principalRepository.ts Outdated
Comment thread control-plane/src/domains/attestation/attestationService.ts

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found four additional blocking issues not covered by the existing review:

  1. Expired attestations are accepted (control-plane/src/grpc/trustEngineClient.ts, control-plane/src/domains/attestation/attestationService.ts:85-153). The verified exp is copied into expires_at, but neither verification nor ingest checks it against the current time. A correctly signed token whose exp is in the past therefore persists successfully. The attestation verification contract requires expiration validation; please reject expired tokens and add a boundary test.

  2. observation_id pairing invariants are not enforced (attestationService.ts:118-155). Ingest only deduplicates the exact token digest, then inserts the row. It never loads an existing (issuer_id, subject_id, observation_id) peer to ensure that attestation_type and trust_delta match. The productization spec requires mismatched public/private pairs to return 4xx; otherwise later grouping can collapse contradictory edges. This check and insert need to be concurrency-safe.

  3. The legacy behavioral@0 escape hatch is unrestricted (schemaValidator.ts:21-23). Any issuer can select schema version 0 and bypass facts validation forever. The spec limits v0 to explicitly allowlisted legacy issuers and requires BEHAVIORAL_V0_CUTOFF, after which it must be rejected. Issuer identity and cutoff policy need to participate in validation.

  4. Supported native attestation types cannot be ingested (schemaValidator.ts:13-15). Every type other than behavioral is rejected, including negative_incident, even though the service explicitly validates its negative delta and the v1 contract defines transaction_summary, kyb, security_audit, and negative_incident. Please add the versioned schemas and tests for each supported type rather than treating them as unknown.

1. Expired attestations are now rejected
   - trustEngineClient.ts: check exp against current time after
     successful signature verification; return invalid if expired
   - Test: expired token is rejected

2. observation_id pairing invariants enforced
   - attestationRepository.ts: added findObservationPeer() to load
     existing attestation with same (issuer_id, subject_id, observation_id)
   - attestationService.ts: after dedup check, if observation_id present,
     load peer and validate attestation_type and trust_delta match;
     reject with 400 on mismatch

3. behavioral@0 allowlist + cutoff
   - schemaValidator.ts: behavioral@0 only accepted from issuers in
     BEHAVIORAL_V0_ALLOWLIST env var; rejected after BEHAVIORAL_V0_CUTOFF
   - Env vars read dynamically (not cached at module load)
   - Tests: allowlist enforcement, cutoff enforcement, non-behavioral @0

4. All native attestation types supported
   - schemaValidator.ts: added transaction_summary@1, kyb@1,
     security_audit@1, negative_incident@1 with per-type required fields
   - Tests: valid + missing-field for each type

Total: 36 TS tests pass, all Go tests pass, typecheck clean.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All four blocking findings fixed in e615dd5:

  1. Expired attestations rejectedtrustEngineClient.ts checks exp after signature verification. Test added.
  2. observation_id pairing invariantsfindObservationPeer() + service validates matching attestation_type and trust_delta.
  3. behavioral@0 allowlist + cutoffBEHAVIORAL_V0_ALLOWLIST + BEHAVIORAL_V0_CUTOFF env vars, read dynamically. Tests for both.
  4. All native types supportedtransaction_summary@1, kyb@1, security_audit@1, negative_incident@1 with per-type required fields. Tests for each.

36 TS tests pass, all Go tests pass, typecheck clean.

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checking e615dd5, the prior findings are not fully resolved and I found one end-to-end blocker:

  1. Go-signed VeriLink attestations cannot be ingested (control-plane/src/grpc/trustEngineClient.ts:71-84). toPayload() reads attestation_type, facts, trust_level_delta, etc. from the JWT top level, but pkg/attestation.AttestationClaims and docs/specs/attestation_schema.md put these signed claims under payload.vli (vli.type, vli.facts, ...). The TS tests use a non-conformant flattened fixture, masking the incompatibility. A token produced by the repository Go client therefore yields an empty type/facts and is rejected. Please parse vli and add the Plan 4 Go-client-to-control-plane integration test.

  2. The observation pairing check remains raceable (attestationService.ts:125-174). Two conflicting first submissions can both observe no peer and then both insert. Additionally, withTransaction() supplies a client that is ignored while repository methods query the global pool, so even the insert is not executed on that transaction. The peer check and insert need one concurrency-safe database operation/transaction, backed by locking or a constraint that makes the invariant enforceable.

  3. An unset allowlist still permits every issuer to use v0 (schemaValidator.ts:8-12). allowlist.length === 0 returns true, contradicting the explicit-allowlist requirement and allowing any new issuer to bypass native schema validation. Missing/empty configuration should fail closed (and GA startup must fail without a valid cutoff); an invalid cutoff currently also silently disables the cutoff. The post-cutoff response also needs the specified 422 rather than the generic 400 path.

  4. The v1 schemas are still not the specified schemas (schemaValidator.ts:34-137). Most validators only check key presence, accept wrong field types and arbitrary extra properties, and enforce neither the 8 KB limit nor depth 4. More directly, behavioral@1 rejects the required nested issuer-defined data object and does not require observation_ts or the schema URI. Please validate against the versioned JSON Schema artifacts required by section 6.4 and test conforming/rejected payloads.

  5. The expiration boundary is off by one second (trustEngineClient.ts:146-151). JWT exp requires current time to be strictly before the expiration time, but now > exp accepts a token during the exact exp second. Use now >= exp and cover the boundary test requested in the original review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@control-plane/src/domains/attestation/attestationService.ts`:
- Around line 143-151: Add observation pairing validation before constructing
the attestation payload: when vp.observationId is present, load the referenced
observation and verify its type and trust delta match vp.attestationType and
vp.trustLevelDelta, rejecting mismatches or missing observations. Keep
observationId unset for attestations without a reference and only persist
validated identifiers in the payload and returned object.

In `@control-plane/src/domains/attestation/jws.ts`:
- Around line 32-65: Extract the base64url decoding and JSON parsing from
preParseJWS in control-plane/src/domains/attestation/jws.ts:32-65 into a shared
segment-decoding helper, preserving the existing validation behavior. Update
parseJws in control-plane/src/grpc/trustEngineClient.ts:43-59 to use that
helper, remove its manual base64urlDecode and '=' padding logic, and use native
Buffer.from(..., 'base64url') through the shared implementation.

In `@control-plane/src/domains/principal/principalRepository.ts`:
- Around line 163-170: Update getKeyByKid to accept the attestation iat and
enforce valid_from <= iat, valid_until being null or >= iat, and revoked_at IS
NULL in its query. Update attestationService.submitAttestation to pass iat when
calling principalRepo.getKeyByKid, preserving direct kid-based key selection
while applying the same validity-window rules as getActiveKeysAt.

In `@control-plane/src/grpc/trustEngineClient.test.ts`:
- Around line 50-159: Extend the verifyAttestation test suite with regression
cases for a non-numeric trust_level_delta claim, a payload missing sub, and an
already-expired exp claim. Use the existing signJws, makeKey, and KeyCandidate
helpers, and assert each result is invalid with an appropriate error matching
the corrected toPayload/verifyAttestation behavior.

In `@control-plane/src/grpc/trustEngineClient.ts`:
- Around line 74-76: Validate string-valued jwsPayload.facts before assigning
factsJson, parsing it as JSON and rejecting or normalizing invalid input so
malformed values produce the established clean 400 response rather than an
uncaught SyntaxError. Preserve JSON.stringify handling for non-string facts and
update the surrounding trust-engine request flow in the relevant client method.
- Around line 110-113: Validate that the JWS payload contains a non-empty sub
claim before deriving subjectId in the trust-engine client flow, matching the
existing issuer validation behavior. Reject the attestation when sub is missing
or empty so createPrincipal is never called with an empty identifier.
- Around line 87-148: Update verifyAttestation to enforce the payload’s exp
claim before accepting a verified attestation: when exp is present, compare it
with the current Unix time and reject tokens that are already expired. Ensure
the rejection returns valid: false with a clear expiration error, while
preserving acceptance of tokens without an exp claim if that is the existing
contract.
- Around line 71-85: Validate numeric claims produced by toPayload before they
reach downstream validation: in verifyAttestation, reject the token when
trustLevelDelta, issuedAtUnix, or expiresAtUnix is not finite, returning an
invalid result with an appropriate claim-specific error. Do not pass NaN or
infinite values to the caller or persistence layer.

In `@control-plane/src/routes/attestations.ts`:
- Around line 12-19: Replace the two inline dynamic imports in the token
validation block with a single top-level static import of AppError and CODES,
then use those imported symbols when throwing the missing-token error. Keep the
existing validation condition and error message unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4193d90c-326e-4a60-ae31-2b2185bb8cb9

📥 Commits

Reviewing files that changed from the base of the PR and between 3a13ff8 and eb0e10b.

📒 Files selected for processing (10)
  • control-plane/src/domains/attestation/attestationService.ts
  • control-plane/src/domains/attestation/jws.test.ts
  • control-plane/src/domains/attestation/jws.ts
  • control-plane/src/domains/attestation/schemaValidator.test.ts
  • control-plane/src/domains/attestation/schemaValidator.ts
  • control-plane/src/domains/principal/principalRepository.ts
  • control-plane/src/grpc/trustEngineClient.test.ts
  • control-plane/src/grpc/trustEngineClient.ts
  • control-plane/src/routes/attestations.ts
  • docs/superpowers/plans/2026-07-27-verilink-4-attestation-ingest.md

Comment thread control-plane/src/domains/attestation/attestationService.ts Outdated
Comment thread control-plane/src/domains/attestation/jws.ts
Comment thread control-plane/src/domains/principal/principalRepository.ts Outdated
Comment thread control-plane/src/grpc/trustEngineClient.test.ts
Comment thread control-plane/src/grpc/trustEngineClient.ts
Comment thread control-plane/src/grpc/trustEngineClient.ts Outdated
Comment thread control-plane/src/grpc/trustEngineClient.ts
Comment thread control-plane/src/grpc/trustEngineClient.ts
Comment thread control-plane/src/routes/attestations.ts
1. Go-signed attestations now ingestible
   - toPayload() reads vli nested claims (per pkg/attestation.AttestationClaims)
     with fallback to top-level for legacy tokens
   - Test: parses vli nested claims (Go-compatible format)

2. observation_id pairing is race-safe
   - Repository methods accept PoolClient for transaction-scoped queries
   - findObservationPeer uses SELECT ... FOR UPDATE
   - dedup + peer check + insert all in one withTransaction(client)

3. behavioral@0 allowlist fails closed
   - Empty/unset allowlist rejects all v0 (was: allowed all)
   - Missing/invalid cutoff rejects all v0 (was: disabled cutoff)
   - Post-cutoff returns 422 (CODES.UNPROCESSABLE) not 400

4. v1 schemas match spec §6.4
   - behavioral@1: requires observation_ts, primitive facts
   - transaction_summary@1: required fields + type checking
   - kyb@1, security_audit@1, negative_incident@1: required fields + types
   - 8KB size limit + depth-4 check on all schemas

5. Expiration boundary fixed
   - now >= exp (was: now > exp) — rejects at exact exp second
   - Test: exp boundary at exact second

40 TS tests pass, all Go tests pass, typecheck clean.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All five findings fixed in 1a6799d:

  1. Go-signed attestations now ingestibletoPayload() reads vli nested claims (matching pkg/attestation.AttestationClaims) with fallback to top-level. Test: parses vli nested claims (Go-compatible format).

  2. observation_id pairing race-safe — Repository methods now accept PoolClient; findObservationPeer uses SELECT ... FOR UPDATE; dedup + peer check + insert all in one withTransaction(client).

  3. Allowlist fails closed — Empty/unset BEHAVIORAL_V0_ALLOWLIST rejects all v0 (was: allowed all). Missing/invalid BEHAVIORAL_V0_CUTOFF rejects all v0. Post-cutoff returns 422 (CODES.UNPROCESSABLE).

  4. v1 schemas match spec §6.4behavioral@1 requires observation_ts; all types have required field + type checking; 8KB size limit + depth-4 check.

  5. Expiration boundary fixednow >= exp (was now > exp). Test: exp boundary at exact exp second.

40 TS tests pass, all Go tests pass, typecheck clean.

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing the latest head 1a6799d, these issues remain beyond the existing CodeRabbit comments:

  1. The observation invariant is still not concurrency-safe (attestationRepository.ts:75-87, attestationService.ts:139-166). SELECT ... FOR UPDATE only locks rows returned by the query. When the first two attestations for an observation arrive concurrently, both transactions can see no peer, acquire no lock, and insert conflicting type/delta values. Use a transaction-scoped advisory lock keyed by (issuer, subject, observation_id), a lockable parent row, or a database constraint/model that enforces the invariant even when no peer exists; add a concurrent integration test.

  2. Legacy Whimsy tokens still select the wrong schema version (trustEngineClient.ts:88). The compatibility contract says an omitted vli.schema_version defaults to "0" for the allowlisted legacy path, but toPayload() still defaults it to "1". A current Whimsy token with no version will therefore be validated as behavioral@1 and rejected. Native v1 must require an explicit version while only the identified legacy format receives the v0 default.

  3. The claimed schema enforcement remains bypassable/incompatible (schemaValidator.ts:43-126). Required fields whose value is null pass both requireString() and requireNumber(). Depth traversal stops at arrays, so nested objects under arrays bypass the depth-four check. The 8 KB limit uses JavaScript string length rather than Buffer.byteLength(json, "utf8"), allowing multibyte payloads larger than 8 KB. Typed schemas also accept arbitrary properties despite additionalProperties: false, while behavioral@1 still rejects the bounded nested issuer-defined data object required by section 6.4. These should be real versioned JSON Schema validations with negative tests for nulls, extras, UTF-8 size, and array nesting.

  4. Lazy subject creation still races (attestationService.ts:130-134). Concurrent distinct attestations for the same previously unseen subject can both observe it missing; one createPrincipal() then fails its primary-key insert and surfaces a database 500 before the transaction/catch block. Make creation idempotent (INSERT ... ON CONFLICT DO NOTHING followed by fetch) and cover concurrent first-seen submissions.

  5. facts_hash is not RFC 8785 JCS (attestationService.ts:11-21). Recursively sorting with locale-dependent localeCompare() and then calling JSON.stringify() is not the required RFC 8785 canonicalization, particularly for property ordering across Unicode keys. This can produce a different hash across runtimes/languages and defeats the cross-language exact-content identity contract. Use a tested JCS implementation and shared Go/TS fixtures.

1. observation_id pairing concurrency-safe via advisory lock
   - pg_advisory_xact_lock keyed by hash(issuer:subject:observation_id)
   - Serializes concurrent first-submissions even when no peer row exists

2. Legacy schema_version defaults correctly
   - vli present without schema_version → defaults to "1" (native)
   - No vli (flattened legacy) → defaults to "0" (allowlisted)
   - Tests: both paths covered

3. Schema enforcement hardened
   - null required fields now rejected (was: passed)
   - Extra properties rejected on typed schemas (additionalProperties: false)
   - Size check uses Buffer.byteLength(json, 'utf8') not string length
   - Depth check traverses into arrays (was: stopped at arrays)
   - behavioral@1 allows bounded nested "data" object per spec §6.4
   - Tests: null fields, extra props, wrong types, behavioral data object

4. Lazy subject creation is idempotent
   - INSERT ... ON CONFLICT (id) DO NOTHING + fetch if existed
   - Concurrent first-seen submissions no longer race on PK violation

5. facts_hash uses RFC 8785 JCS (not localeCompare)
   - Sort by UTF-16 code unit order (charCodeAt), not locale
   - Drop undefined values
   - Deterministic across runtimes/languages
   - Tests: UTF-16 ordering, deterministic hash, nested objects, arrays

52 TS tests pass, all Go tests pass, typecheck clean.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All five findings fixed in b05968f:

  1. Advisory lock for observation_idpg_advisory_xact_lock keyed by hash(issuer:subject:observation_id) serializes concurrent first-submissions even when no peer row exists yet.

  2. Legacy schema_version defaultvli present without schema_version → defaults to "1" (native). No vli (flattened legacy) → defaults to "0" (allowlisted). Tests for both paths.

  3. Schema enforcement hardened — null required fields rejected; extra properties rejected on typed schemas (additionalProperties: false); size check uses Buffer.byteLength(json, 'utf8'); depth check traverses arrays; behavioral@1 allows bounded nested data object per spec §6.4.

  4. Idempotent subject creationINSERT ... ON CONFLICT (id) DO NOTHING + fetch if existed. Concurrent first-seen no longer races on PK violation.

  5. RFC 8785 JCS — Sort by UTF-16 code unit order (charCodeAt), not localeCompare. Drop undefined values. Deterministic across runtimes. Tests: UTF-16 ordering, deterministic hash, nested/arrays.

52 TS tests pass, all Go tests pass, typecheck clean.

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing round-three head b05968f, I found four remaining issues not covered by the old CodeRabbit review:

  1. Every attestation with observation_id crashes in the ESM runtime (attestationRepository.ts:96-99). The package has "type": "module" and compiles with NodeNext, but hashToLockKey() calls require("node:crypto"); require is undefined in an ES module. The advisory-lock path therefore throws before querying PostgreSQL. Use a top-level ESM import (import { createHash } from "node:crypto") and exercise this through an actual repository/service integration test.

  2. Schema-version inference still contradicts both compatibility paths (trustEngineClient.ts:79-84). VeriLink/Go and the documented Whimsy format both nest claims under vli; Whimsy is distinguished by its allowlisted issuer and omitted version, not by flattening the payload. The current hasVli ? "1" : "0" therefore treats a real legacy Whimsy token as v1, while also silently upgrading a native token that violates the requirement that v1 specify schema_version. Preserve “version absent” through verification, then let ingest select v0 only for an allowlisted legacy issuer; reject it for native issuers.

  3. The behavioral nested-data cap is not enforced (schemaValidator.ts:137-156). Section 6.4 allows a nested issuer-defined data object up to 4 KB, but validation only applies the 8 KB whole-facts limit. A ~7 KB data object is accepted. Measure facts.data separately at 4096 UTF-8 bytes and add boundary tests. More broadly, the required versioned JSON Schema artifacts are still absent from docs/specs, so this remains a hand-written approximation rather than validation against the schemas required before ingest.

  4. The new JCS tests do not test production code (canonicalize.test.ts:5-44). They copy and test a second implementation of canonicalize() instead of importing or reaching the function used by submitAttestation; a regression in the service implementation leaves every test green. Move canonicalization to an exported module, test that exact function with official RFC 8785 vectors (including numeric/string edge cases), and add the cross-language Go/TS hash fixture required by the spec.

1. ESM runtime crash fixed
   - hashToLockKey() used require('node:crypto') which is undefined in ESM
   - Replaced with top-level import { createHash } from 'node:crypto'

2. Schema version inference corrected
   - toPayload() now preserves empty string when schema_version absent
   - attestationService resolves: allowlisted issuer → "0", native → reject
   - No more guessing v1 or v0 in the verifier; ingest owns the decision

3. behavioral@1 nested data cap enforced
   - facts.data measured separately with Buffer.byteLength at 4KB limit
   - Tests: data under 4KB accepted, over 4KB rejected

4. JCS tests now test production code
   - canonicalize() and computeFactsHash() exported from canonicalize.ts
   - attestationService imports from canonicalize.ts (not inline copy)
   - Tests import and exercise the exact production function
   - Tests: UTF-16 ordering, deterministic hash, nested/arrays, numbers, booleans

58 TS tests pass, all Go tests pass, typecheck clean.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All four findings fixed in 3e208e7:

  1. ESM crash fixedhashToLockKey() replaced require('node:crypto') with top-level import { createHash }. Every attestation with observation_id now works in ESM runtime.

  2. Schema version inference correctedtoPayload() preserves empty string when schema_version is absent. attestationService resolves it: allowlisted issuer → "0", native issuer → reject (400). The verifier no longer guesses; ingest owns the decision.

  3. behavioral@1 data cap enforcedfacts.data measured separately with Buffer.byteLength at 4096 bytes. Tests: under 4KB accepted, over 4KB rejected.

  4. JCS tests test production codecanonicalize() and computeFactsHash() exported from canonicalize.ts. Service imports the module. Tests exercise the exact production function with UTF-16 ordering, deterministic hash, nested/arrays, numbers, booleans.

58 TS tests pass, all Go tests pass, typecheck clean.

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing round-four head 3e208e7, I found five remaining issues:

  1. The production canonicalizer still produces non-JCS ordering for integer-like keys (canonicalize.ts:14-31). It sorts entries lexically and then rebuilds a plain object, but JSON.stringify() enumerates integer-index property names numerically regardless of insertion order. For example {"10":"a","2":"b"} is emitted as {"2":"b","10":"a"}, while RFC 8785 UTF-16 ordering requires "10" before "2". Behavioral data permits these keys, so cross-language facts_hash can still differ. Serialize sorted entries directly or use a vetted JCS library, and add this regression plus official RFC vectors.

  2. Native v1 still accepts a missing kid (attestationService.ts:62-91). The no-kid branch tries all active issuer keys before schema resolution and there is no later check restricting that fallback to legacy v0. A native v1 token with no kid therefore verifies and persists, contrary to the requirement that kid is mandatory for native submissions. After resolving the effective schema version, reject missing kid unless the submission is the allowlisted legacy path.

  3. Legacy subjects are not converted to VeriLink principal IDs (attestationService.ts:126-130). Whimsy subjects are legacy fingerprints/strings; the compatibility contract requires lazy creation of a new vrl:p:<uuid> principal and preservation of the source value in metadata.legacy_did. The service still inserts the raw signed sub as principals.id, so the first seeded issuer creates identifiers outside the native namespace and loses the legacy mapping metadata.

  4. The normalized stored payload records the unresolved schema version (attestationService.ts:162-174). For an allowlisted legacy token, schemaVersion is resolved to "0", but payload.schema_version is built from vp.schemaVersion, which remains empty. The JSONB payload and schema_version column therefore disagree for every compatibility submission. Use the resolved schemaVersion consistently and add a legacy persistence test.

  5. Invalid visibility becomes a database 500 instead of a validation 4xx (trustEngineClient.ts:90, attestationService.ts:162-179). Any supplied value is string-coerced and persisted without checking participants | public; PostgreSQL then raises the visibility CHECK violation (23514), which is not mapped and escapes as an internal error. Validate the signed claim before persistence and test invalid values. The same validation layer should reject malformed optional identifiers rather than relying on database failures.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
control-plane/src/domains/attestation/attestationService.ts (1)

167-173: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Stored payload.schema_version and the schema_version column disagree for legacy tokens.

Line 173 persists the resolved schemaVersion ('0' for allowlisted legacy issuers), but Line 167 embeds the raw vp.schemaVersion in the JSON payload, which is '' in exactly that case — toPayload preserves absent versions as an empty string by design. Two records of the same fact, disagreeing. Anything auditing or replaying from payload will read a blank version.

🐛 Proposed fix
-        payload: { type: vp.attestationType, facts, trust_level_delta: vp.trustLevelDelta, schema_version: vp.schemaVersion, visibility: vp.visibility, observation_id: vp.observationId },
+        payload: { type: vp.attestationType, facts, trust_level_delta: vp.trustLevelDelta, schema_version: schemaVersion, visibility: vp.visibility, observation_id: vp.observationId },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/domains/attestation/attestationService.ts` around lines 167
- 173, Update the payload construction in the attestation persistence flow to
use the resolved schemaVersion for payload.schema_version instead of the raw
vp.schemaVersion. Keep the schema_version column and JSON payload aligned,
including the legacy allowlisted issuer path that resolves an absent version to
'0'.
♻️ Duplicate comments (4)
control-plane/src/domains/principal/principalRepository.ts (1)

151-188: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

getKeyByKid still ignores the key validity window that getActiveKeysAt enforces.

Previously flagged and unchanged: this query filters only on principal_id, key_id, and revoked_at IS NULL, while getActiveKeysAt additionally requires valid_from <= iat AND (valid_until IS NULL OR valid_until >= iat). Since attestationService.submitAttestation (Line 53-58) takes the getKeyByKid path whenever the JWS header carries a kid, an issuer can sign with a not-yet-valid or already-rolled-over key and still pass verification simply by naming that kid. Key rotation is unenforceable on the fast path.

Thread iat through and apply the same predicate; the call site at attestationService.ts Line 54 needs to pass it.

🔒 Proposed fix
-export async function getKeyByKid(principalId: string, keyId: string): Promise<PrincipalKey | null> {
+export async function getKeyByKid(principalId: string, keyId: string, iat: Date): Promise<PrincipalKey | null> {
   const { rows } = await pool.query(
     `SELECT * FROM principal_keys
-     WHERE principal_id = $1 AND key_id = $2 AND revoked_at IS NULL`,
-    [principalId, keyId]
+     WHERE principal_id = $1 AND key_id = $2 AND revoked_at IS NULL
+       AND valid_from <= $3
+       AND (valid_until IS NULL OR valid_until >= $3)`,
+    [principalId, keyId, iat]
   );
   return rows[0] || null;
 }
#!/bin/bash
# Confirm getKeyByKid's current predicate and all call sites.
fd -t f 'principalRepository.ts' --exec ast-grep run --pattern 'export async function getKeyByKid($$$) { $$$ }' --lang typescript {}
rg -nP --type=ts -C3 '\bgetKeyByKid\s*\('
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/domains/principal/principalRepository.ts` around lines 151
- 188, Update getKeyByKid to accept an iat argument and apply the same
valid_from/valid_until validity-window predicates used by getActiveKeysAt, while
retaining the principal_id, key_id, and non-revoked filters. Update
attestationService.submitAttestation’s getKeyByKid call to pass the attestation
iat value.
control-plane/src/grpc/trustEngineClient.ts (2)

88-90: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Pre-serialized facts string is still passed through unvalidated.

Previously flagged and unchanged apart from the vli indirection. attestationService.ts Line 96 does JSON.parse(vp.factsJson) inside a try that only special-cases SchemaValidationError, so a token with vli.facts: "hello" throws an uncaught SyntaxError (500) instead of a 400. Line 123 parses it a second time outside any try.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/grpc/trustEngineClient.ts` around lines 88 - 90, The
factsJson construction in the trust-engine client must validate pre-serialized
vli.facts strings before forwarding them. Update the flow around vli.facts and
the corresponding attestationService parsing to reject invalid or non-object
JSON with the existing 400/schema-validation error path, avoiding uncaught
SyntaxError failures and unsafe repeated parsing.

91-93: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

toPayload maps untrusted JWS claims into typed fields with no validation. Both findings below share one root cause: decoded claim values are coerced (Number(...), pass-through string) and handed to callers as if they were validated, so malformed claims defeat downstream checks instead of being rejected. Both were raised on earlier commits and survived the vli refactor.

  • control-plane/src/grpc/trustEngineClient.ts#L91-L93: reject non-finite results from Number(...) for trust_level_delta, iat, and exp. NaN currently bypasses the trust_delta range/sign checks in attestationService.ts, fails open on the new expiration guard at Line 161, and yields an Invalid Date for issued_at.
  • control-plane/src/grpc/trustEngineClient.ts#L88-L90: when vli.facts is already a string, parse it before accepting it as factsJson, so the JSON.parse calls at attestationService.ts Lines 96 and 123 cannot throw an uncaught SyntaxError and turn a bad token into a 500.

The cleanest shape is to have verifyAttestation return { valid: false, error } for any claim that fails these checks, rather than forwarding corrupt values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/grpc/trustEngineClient.ts` around lines 91 - 93, Update
verifyAttestation and its toPayload claim mapping in
control-plane/src/grpc/trustEngineClient.ts at lines 91-93 and 88-90: reject
non-finite Number results for trust_level_delta, iat, and exp by returning {
valid: false, error }, and parse string-valued vli.facts before assigning
factsJson, likewise returning an invalid result when parsing fails. Ensure
malformed claims are never forwarded to attestationService.ts.
control-plane/src/grpc/trustEngineClient.test.ts (1)

192-264: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression cases for the still-open toPayload gaps.

The vli mapping coverage is good, but there is still no test for a non-numeric trust_level_delta, a non-JSON string facts, or a payload missing sub — all three remain unhandled in trustEngineClient.ts. Adding them here would lock in the fixes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/grpc/trustEngineClient.test.ts` around lines 192 - 264, Add
regression tests alongside the existing attestation parsing tests for
non-numeric vli trust_level_delta, non-JSON string facts, and a signed payload
missing sub. Use verifyAttestation and assert each case’s expected invalid or
fallback behavior, covering the corresponding toPayload handling gaps in
trustEngineClient.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@control-plane/src/domains/attestation/attestationRepository.ts`:
- Around line 76-95: Make the client parameter required in findObservationPeer
and remove the pool fallback, ensuring every caller uses a transactional
PoolClient before acquiring the advisory lock and querying attestations. Update
callers such as attestationService to satisfy the required client contract.
- Around line 97-100: Update hashToLockKey’s caller when passing lockKey to
pg_advisory_xact_lock so the bigint is converted to a string before becoming a
query parameter. Preserve the existing hash calculation and advisory-lock
behavior, changing only the parameter serialization.

In `@control-plane/src/domains/attestation/attestationService.ts`:
- Around line 133-139: Update the comment above
attestationRepo.findByTokenDigest in the withTransaction callback to remove the
claim that the dedup check uses a lock. Describe it as an in-transaction
duplicate check, and preserve the existing 23505 unique-constraint handling that
provides concurrency protection.
- Around line 126-130: Validate verifiedSubjectId before principal lookup and
reject missing or empty subjects before any database work. Move the lazy
principal creation into the attestation withTransaction callback so it commits
or rolls back with the attestation, and update the principal repository methods
used by this flow (getPrincipal and createPrincipal) to accept and use the
transaction PoolClient rather than the shared pool.
- Around line 82-93: Remove the local BEHAVIORAL_V0_ALLOWLIST parsing from the
schema-version resolution block and import/reuse the exported
isV0AllowedForIssuer predicate from schemaValidator.ts with verifiedIssuerId.
Ensure the predicate remains the single source of truth for legacy v0
eligibility before assigning the default version or throwing the existing
AppError.

In `@control-plane/src/domains/attestation/canonicalize.test.ts`:
- Around line 5-35: Add a test alongside the existing canonicalize ordering
cases that uses at least one non-BMP Unicode object key and asserts the exact
RFC 8785 UTF-16 code-unit ordering in the canonical output. Keep the assertion
focused on canonicalize’s serialized key order so any comparator change is
detected.

---

Outside diff comments:
In `@control-plane/src/domains/attestation/attestationService.ts`:
- Around line 167-173: Update the payload construction in the attestation
persistence flow to use the resolved schemaVersion for payload.schema_version
instead of the raw vp.schemaVersion. Keep the schema_version column and JSON
payload aligned, including the legacy allowlisted issuer path that resolves an
absent version to '0'.

---

Duplicate comments:
In `@control-plane/src/domains/principal/principalRepository.ts`:
- Around line 151-188: Update getKeyByKid to accept an iat argument and apply
the same valid_from/valid_until validity-window predicates used by
getActiveKeysAt, while retaining the principal_id, key_id, and non-revoked
filters. Update attestationService.submitAttestation’s getKeyByKid call to pass
the attestation iat value.

In `@control-plane/src/grpc/trustEngineClient.test.ts`:
- Around line 192-264: Add regression tests alongside the existing attestation
parsing tests for non-numeric vli trust_level_delta, non-JSON string facts, and
a signed payload missing sub. Use verifyAttestation and assert each case’s
expected invalid or fallback behavior, covering the corresponding toPayload
handling gaps in trustEngineClient.ts.

In `@control-plane/src/grpc/trustEngineClient.ts`:
- Around line 88-90: The factsJson construction in the trust-engine client must
validate pre-serialized vli.facts strings before forwarding them. Update the
flow around vli.facts and the corresponding attestationService parsing to reject
invalid or non-object JSON with the existing 400/schema-validation error path,
avoiding uncaught SyntaxError failures and unsafe repeated parsing.
- Around line 91-93: Update verifyAttestation and its toPayload claim mapping in
control-plane/src/grpc/trustEngineClient.ts at lines 91-93 and 88-90: reject
non-finite Number results for trust_level_delta, iat, and exp by returning {
valid: false, error }, and parse string-valued vli.facts before assigning
factsJson, likewise returning an invalid result when parsing fails. Ensure
malformed claims are never forwarded to attestationService.ts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 58d82b62-1b22-40ec-bcff-1f73720f7326

📥 Commits

Reviewing files that changed from the base of the PR and between eb0e10b and 3e208e7.

📒 Files selected for processing (9)
  • control-plane/src/domains/attestation/attestationRepository.ts
  • control-plane/src/domains/attestation/attestationService.ts
  • control-plane/src/domains/attestation/canonicalize.test.ts
  • control-plane/src/domains/attestation/canonicalize.ts
  • control-plane/src/domains/attestation/schemaValidator.test.ts
  • control-plane/src/domains/attestation/schemaValidator.ts
  • control-plane/src/domains/principal/principalRepository.ts
  • control-plane/src/grpc/trustEngineClient.test.ts
  • control-plane/src/grpc/trustEngineClient.ts

Comment thread control-plane/src/domains/attestation/attestationRepository.ts
Comment thread control-plane/src/domains/attestation/attestationRepository.ts Outdated
Comment thread control-plane/src/domains/attestation/attestationService.ts
Comment thread control-plane/src/domains/attestation/attestationService.ts Outdated
Comment thread control-plane/src/domains/attestation/attestationService.ts
Comment thread control-plane/src/domains/attestation/canonicalize.test.ts
1. JCS integer-key ordering fixed
   - Canonicalizer now serializes sorted entries directly to string
     instead of rebuilding a plain object (JS engine reorders integer keys)
   - '10' before '2' per RFC 8785 UTF-16 order, not numeric order
   - Tests: integer-like keys, mixed integer/string keys

2. Native v1 kid enforcement
   - After schema version resolution, reject missing kid unless legacy v0
   - isLegacy flag tracks allowlisted v0 path; native v1 requires kid

3. Legacy subject → vrl:p:<uuid> mapping
   - Non-vrl:p: subjects (did:key, fingerprints) get a new vrl:p:<uuid> principal
   - Original subject preserved in metadata.legacy_did
   - createPrincipal() now accepts metadata parameter

4. Persisted payload uses resolved schema_version
   - payload.schema_version set to resolved value (was: raw vp.schemaVersion)

5. Visibility validated before persistence
   - Reject values other than 'participants' or 'public' with 400
   - Prevents database CHECK violation (23514) from surfacing as 500

60 TS tests pass, all Go tests pass, typecheck clean.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All five findings fixed in d01a53e:

  1. JCS integer-key ordering — Canonicalizer now serializes sorted entries directly to string (not rebuilding a plain object that JS reorders). {"10":"a","2":"b"} stays in UTF-16 order. Tests: integer-like keys, mixed keys.

  2. Native v1 kid enforcement — After schema version resolution, missing is rejected for native v1 (400). Legacy v0 allowlisted issuers may omit it. flag tracks the path.

  3. Legacy subject → vrl:p: — Non- subjects (did:key, fingerprints) get a new principal with preserving the original. now accepts a parameter.

  4. Persisted payload uses resolved schema_version — set to the resolved value (was: raw empty string for legacy).

  5. Visibility validated before persistence — Rejects values other than / with 400. Prevents database CHECK violation from surfacing as 500.

60 TS tests pass, all Go tests pass, typecheck clean.

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing round-five head d01a53e, I found four new issues beyond CodeRabbit’s existing findings:

  1. Legacy subject mapping creates a new principal for every attestation (attestationService.ts:143-158). The lookup uses getPrincipal(verifiedSubjectId), but the created row is stored under a random vrl:p:<uuid> with the legacy value only in metadata. The next token for the same legacy sub cannot find that row and creates another UUID, fragmenting one subject across multiple graph nodes; concurrent first submissions can do the same. Add an atomic repository lookup/upsert by the original legacy identifier, backed by a unique indexed mapping (not an unindexed best-effort JSON scan), and use the returned native ID consistently.

  2. Explicit legacy v0 tokens are incorrectly required to carry kid (attestationService.ts:85-100). isLegacy is set only when an absent version is defaulted to "0". If an allowlisted legacy issuer explicitly signs schema_version: "0" and omits kid, isLegacy remains false and the native-kid check rejects it. Derive legacy status from the resolved version plus the single allowlist predicate, not from whether defaulting happened, and test both omitted and explicit v0.

  3. An explicitly empty visibility is silently accepted as participants (attestationService.ts:127-132). vp.visibility || "participants" treats signed visibility: "" as absent, bypassing the new enum validation; persistence also records an empty value in payload.visibility while the column contains participants. Since toPayload() already supplies the compatibility default only when the claim is nullish, validate vp.visibility directly and use the same normalized value in both representations.

  4. The JCS implementation accepts invalid Unicode that RFC 8785 requires rejecting (canonicalize.ts:36-38). JSON.stringify() escapes lone UTF-16 surrogates instead of failing, so facts containing a JSON string such as "\\ud800" are hashed even though RFC 8785/I-JSON requires lone surrogates to terminate canonicalization. Cross-language decoders may replace or reject that value, recreating the hash mismatch this code is meant to prevent. Use a compliant JCS implementation or explicitly validate Unicode, and add official RFC vectors rather than only locally constructed ordering cases.

1. Legacy subject mapping no longer fragments
   - findOrCreateByLegacyDID() in principalRepository: looks up by
     metadata->>'legacy_did', creates vrl:p:<uuid> atomically
   - ON CONFLICT + re-query handles concurrent first submissions
   - Same legacy sub always resolves to the same native principal

2. Legacy v0 with explicit schema_version: '0' now accepted without kid
   - isLegacy derived from (allowlisted issuer + version '0'), not
     from whether defaulting happened
   - Explicit v0 from allowlisted issuer correctly skips kid requirement

3. Empty visibility rejected, not silently defaulted
   - toPayload() defaults nullish to 'participants' (already correct)
   - Service validates vp.visibility directly; explicit '' is rejected
   - Payload uses the same validated visibility value

4. JCS rejects lone surrogates per RFC 8785/I-JSON
   - canonicalizeString() checks for unpaired high/low surrogates
   - Throws on violation instead of passing to JSON.stringify
   - Tests: lone high, lone low, valid surrogate pair (emoji)

63 TS tests pass, all Go tests pass, typecheck clean.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All four findings fixed in e64dbb3:

  1. Legacy subject mappingfindOrCreateByLegacyDID() looks up by metadata->>legacy_did, creates vrl:p:<uuid> atomically with ON CONFLICT. Same legacy sub always resolves to the same native principal. No more fragmentation.

  2. Legacy v0 with explicit versionisLegacy derived from (allowlisted issuer + version "0"), not from whether defaulting happened. Explicit schema_version: "0" from allowlisted issuer correctly skips kid requirement.

  3. Empty visibility — Service validates vp.visibility directly. Explicit "" is rejected (toPayload already defaults nullish to "participants"). Payload and column use the same validated value.

  4. JCS lone surrogatescanonicalizeString() checks for unpaired high/low surrogates and throws. Tests: lone high, lone low, valid surrogate pair (emoji).

63 TS tests pass, all Go tests pass, typecheck clean.

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing round-six head e64dbb3, I found three remaining issues introduced or left unresolved by this revision:

  1. The legacy mapping is still not atomic under concurrency (control-plane/src/domains/principal/principalRepository.ts:53-83). Two requests for the same unseen legacy DID can both miss the initial SELECT, generate different random UUIDs, and both successfully insert because ON CONFLICT (id) only protects those different UUIDs. The retry path is never reached, so the same legacy subject is again split across graph nodes. Back the mapping with a unique indexed column/expression (or mapping table) and perform an atomic upsert returning the one canonical principal; add a concurrent first-ingest test.

  2. Malformed native subjects are silently converted into legacy principals (control-plane/src/domains/attestation/attestationService.ts:142-153). The conversion is selected by !subjectId.startsWith('vrl:p:'), not by isLegacy. Thus a native v1 token signed with sub: "arbitrary" is accepted and mapped instead of rejected, while strings such as vrl:p:not-a-uuid bypass conversion and are stored as native IDs. Only the allowlisted legacy-v0 path should map a legacy subject; native submissions should require and validate the complete vrl:p:<uuid> form.

  3. The new lone-surrogate rejection is exposed as a 500 (control-plane/src/domains/attestation/attestationService.ts:137-140, canonicalize.ts:42-54). Schema validation accepts string values containing a lone surrogate, then computeFactsHash() throws a plain Error outside the validation catch. The global mapper turns that into INTERNAL, so attacker-controlled malformed facts now produce a server error rather than a deterministic validation response. Validate JCS compatibility within the schema/4xx boundary or translate canonicalization failures to BAD_REQUEST, with a submit-level regression test rather than only a unit test of canonicalize().

1. Atomic legacy mapping backed by unique index
   - Migration 007: unique index on (metadata->>'legacy_did')
   - findOrCreateByLegacyDID: INSERT without ON CONFLICT so concurrent
     inserts on the same legacy DID hit 23505 (unique violation) and
     re-query. No more fragmentation across graph nodes.

2. Native v1 subjects validated, only legacy v0 maps
   - Legacy subject mapping gated on isLegacy (allowlisted v0 path only)
   - Native v1 requires vrl:p:<uuid> format with UUID regex validation
   - Arbitrary strings and malformed vrl:p: IDs rejected with 400

3. JCS surrogate error mapped to 400
   - computeFactsHash() wrapped in try/catch
   - Lone surrogate Error translated to AppError(BAD_REQUEST)
   - No more 500 on attacker-controlled malformed facts

63 TS tests pass, all Go tests pass, typecheck clean.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All three findings fixed in 70583a8:

  1. Atomic legacy mapping — Migration 007 adds a unique index on (metadata->>legacy_did). findOrCreateByLegacyDID now INSERTs without ON CONFLICT so concurrent inserts on the same legacy DID hit 23505 and re-query. No more fragmentation.

  2. Native v1 subjects validated — Legacy mapping gated on isLegacy (allowlisted v0 path only). Native v1 requires vrl:p:<uuid> format with UUID regex validation. Arbitrary strings rejected with 400.

  3. JCS surrogate error → 400computeFactsHash() wrapped in try/catch. Lone surrogate Error translated to AppError(BAD_REQUEST). No more 500 on malformed facts.

63 TS tests pass, all Go tests pass, typecheck clean.

@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

All three blockers fixed in ba1d6b2:

  1. Scopes enforcedrequireScope middleware checks attest:write on /submit and attest:read on GET. API keys without the scope get 403. OIDC users require staff/admin role.

  2. Participant-only facts filteredlistAttestations joins principals for owner_tenant_id on both issuer and subject. visibility=participants rows only returned if caller owns issuer or subject. public rows visible to all. callerTenantId threaded through route → service → repository.

  3. Native v1 required claimstoPayload tracks trustLevelDeltaProvided and visibilityProvided. Service rejects native v1 without explicit trust_level_delta and visibility; legacy v0 inherits defaults.

64 TS tests pass, all Go tests pass, typecheck clean.

subjectId: req.query.subject_id as string,
limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined,
offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined,
callerTenantId: req.user?.tenantId || undefined,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The visibility fix still uses an arbitrary single tenant and does not implement staff access. authMiddleware obtains OIDC context with SELECT ... FROM tenant_memberships WHERE user_id = $1 LIMIT 1 and no ordering or active-tenant selection, then this route forwards only that one tenant ID. A user who owns/participates in multiple tenants therefore sees participant attestations for whichever membership PostgreSQL happens to return, while participant rows belonging to their other tenant are hidden. Also, although the stated rule and repository comment allow staff, staff/admin are passed through this same tenant predicate and never receive a privileged path. Resolve an explicit active tenant (or all authorized tenant IDs) and pass a separate, auditable staff bypass; add route/repository tests for multi-tenant and staff callers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d81410c.

  1. OIDC auth now loads ALL tenant memberships (was: LIMIT 1). req.user.tenantIds contains every authorized tenant ID.

  2. listAttestations uses ANY(uuid[]) — participant rows visible if caller owns the issuer OR subject principal via ANY of their tenant IDs. Multi-tenant users see all their participant rows.

  3. Staff/admin bypass: req.user.isStaff = true if any role is staff/admin. Staff bypass all visibility filters entirely.

1. OIDC auth loads all tenant memberships (was: LIMIT 1)
   - req.user.tenantIds: all authorized tenant IDs
   - req.user.isStaff: true if any role is staff/admin

2. listAttestations uses ANY(uuid[]) for multi-tenant
   - Participant rows visible if caller owns issuer OR subject
     via any of their tenant IDs
   - Staff/admin bypass all visibility filters

3. Route passes tenantIds[] + isStaff to service/repo

64 TS tests pass, all Go tests pass, typecheck clean.
Comment thread control-plane/src/middleware/auth.ts Outdated
role: memberships[0]?.role || 'member',
tenantIds: memberships.map((m: { tenant_id: string }) => m.tenant_id),
roles: memberships.map((m: { role: string }) => m.role),
isStaff: memberships.some((m: { role: string }) => m.role === 'staff' || m.role === 'admin'),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflates tenant admin with global staff and creates a cross-tenant data bypass. tenant_memberships.role is scoped to one tenant, but an admin membership in any tenant sets this request-wide isStaff bit. listAttestations() then skips its visibility predicate entirely, so an administrator of tenant A can read participant-only facts owned exclusively by tenants B/C. At the same time, requireScope() still checks only req.user.role from the first ORDER BY tenant_id membership, so a genuine staff membership in a later tenant is denied before reaching the route. Keep tenant-admin authority paired with its tenant ID, derive any platform-wide staff privilege from an explicit global role/claim, and make both scope enforcement and repository filtering consume the same authorization model. Add negative coverage proving a tenant admin cannot read another tenant’s participant facts.

Sanjay added 2 commits July 27, 2026 15:24
…n no longer bypass visibility

- Add users.platform_role column for true platform-wide staff/admin
- Derive isStaff from platform_role, not tenant_memberships.role
- requireScope checks all tenant memberships for elevated roles
- Tenant-scoped admin/staff does not bypass cross-tenant visibility
…rcement

- Prove tenant admin (isStaff=false) gets scope access via tenant role
- Prove tenant member cannot access attest:write
- Prove multi-tenant user with admin in one tenant can access
- Prove platform staff still bypasses all scope checks
- Prove unauthenticated users are denied
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

Fixed in c4ad10c.

The three changes:

  1. Added users.platform_role column for true platform-wide staff/admin — migration 008_platform_role
  2. authMiddleware now derives isStaff from users.platform_role, not from tenant_memberships.role
  3. requireScope checks isStaff (platform staff) for bypass, and checks req.user.roles (all memberships) for tenant-scoped scope grants

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final review of 0759157: the explicit platform_role separation fixes the global-staff conflation, and the prior native-claim blocker is also resolved. One tenant-bound authorization blocker remains in the new scope implementation; details are inline.

// grants the scope. Tenant admin/staff can read/write attestations
// within their tenant; members are denied by default.
const roles = req.user.roles || [];
const hasElevatedRole = roles.some(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tenant role is still detached from the tenant it authorizes. For a user who is admin in tenant A and only member in tenant B, this some() check admits the request, then attestations.ts forwards both tenant IDs and listAttestations() applies ANY(callerTenantIds). That exposes participant-only facts owned by B even though the B membership does not grant attest:read. The new mixed-role test proves only endpoint admission and therefore misses this cross-tenant escalation. Preserve membership pairs (or derive elevatedTenantIds) and pass only tenants whose membership grants the requested scope into tenant-filtered operations; add a route/repository negative test for admin-A/member-B reading B-only participant data.

Sanjay added 2 commits July 27, 2026 15:34
- Route handler now filters callerTenantIds to only include tenants
  where the user has an elevated role (admin/staff), not all tenants
  they belong to. A member in tenant B can no longer see participant-
  only data from tenant B through the elevated admin role in tenant A.
- Auth type augmentation adds tenantIds, roles, isStaff fields
- attestationService.listAttestations already accepted these params
…ipant data bypass

Prove that callerTenantIds is filtered to only elevated-role tenants,
preventing a member in tenant B from seeing participant-only data via
an admin role in tenant A.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

Fixed in dcccf48.

The root cause: callerTenantIds was passed all tenant IDs the user belongs to, so member-only tenants leaked into the ANY() visibility check and exposed participant-only data from those tenants.

Fix in attestations.ts route handler — callerTenantIds is now filtered to only include tenants where the user has an elevated role (admin/staff). A user who is admin in tenant A and member in tenant B will only see participant-only data from tenant A; tenant B entries are restricted to visibility = 'public'.

Added 3 tests in requireScope.test.ts proving the tenant-role pairing:

  1. admin in A + member in B → callerTenantIds = [A] only
  2. member in both → callerTenantIds = [] (public-only)
  3. admin in A + staff in B → callerTenantIds = [A, B]

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of dcccf48: the admin-A/member-B OIDC visibility escalation from review 4790778190 is fixed. One regression remains in the same tenant filtering path; details are inline.

async handler(req, res) {
const tenantIds = req.user?.tenantIds || (req.user?.tenantId ? [req.user.tenantId] : []);
const roles = req.user?.roles || [];
const callerTenantIds = tenantIds.filter((_, i) => roles[i] === 'staff' || roles[i] === 'admin');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removes participant visibility from every scoped API key. API-key identities have tenantId plus scopes, but no roles; after requireScope('attest:read') accepts such a key, roles is empty and this filter produces [], so the repository returns only public attestations rather than participant rows owned by the key's tenant. Branch on identity type: preserve the API key's authenticated tenant ID, and apply the elevated-role filter only to OIDC memberships. Add an API-key route/service regression test. The three new tests currently copy this expression into local arrays rather than invoking route production code, so they cannot catch this behavior or future drift.

API keys have no roles array, so the role-based filter was stripping
all tenant IDs and leaving callerTenantIds empty — restricting them
to public attestations only.

Fix: when roles array is empty (API key path), pass tenantIds
unfiltered. requireScope already enforces scope-based access for
API keys.

Added attestations.test.ts with tests proving:
- API key tenantId passes through unfiltered
- OIDC tenant admin gets only their elevated tenants
- OIDC member-only user gets empty callerTenantIds (public only)
- API keys retain tenant access for participant data
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

Fixed in ab58d0d.

The regression: API keys have no roles array, so the role-based filter in the route handler was stripping all tenant IDs and leaving callerTenantIds = [] — restricting them to public attestations only and losing access to participant attestations from their own tenant.

Fix: when roles is empty (API key path), pass tenantIds unfiltered since requireScope already enforces scope-based access independently of tenant roles.

New test file attestations.test.ts with 8 tests covering:

  1. API key tenantId passes through unfiltered (no roles array)
  2. OIDC tenant admin gets only their elevated tenants
  3. OIDC member-only user gets empty callerTenantIds (public only)
  4. OIDC platform staff gets all elevated-role tenants
  5. API keys retain tenant access for participant data
  6. API key scope enforcement still works correctly

@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

Fixed in ab58d0d.

The callerTenantIds filter in attestations.ts was stripping all tenant IDs for API keys (which have no roles array). Fix: when roles is empty, pass tenantIds unfiltered so API keys retain access to their own tenant's participant data. requireScope independently enforces scope-based access.

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of ab58d0d: the API-key participant-visibility regression is fixed, and I found no new production bug in this incremental change. One requested regression-coverage gap remains; details are inline.

import assert from 'node:assert/strict';
import { requireScope } from '../middleware/requireScope.js';

function resolveCallerTenantIds(user: {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests do not exercise the route implementation they are intended to protect. resolveCallerTenantIds() is a test-local copy of the expression in attestations.ts; changing or regressing the route leaves every test here green. The claimed participant-data case also only asserts an ID is present and never reaches listAttestations(). Move the resolver into production code and import it here, or mount the router and spy/mock attestationService.listAttestations to assert the actual callerTenantIds passed for API-key and mixed-role OIDC requests. Prefer branching on user.type === 'apikey' in that production resolver rather than inferring identity type from an empty roles array, so authorization fails closed if an OIDC context is incomplete.

…irectly

Extract tenant-role filtering logic from attestations.ts route handler
into control-plane/src/lib/tenantFilter.ts so it is shared between
the route implementation and the test suite. Tests now exercise the
real code via import, eliminating the duplication that masked the
API-key regression.
@messagesgoel-blip

Copy link
Copy Markdown
Collaborator Author

Fixed in 649a8a1.

Extracted resolveCallerTenantIds into control-plane/src/lib/tenantFilter.ts so both the route handler and its test suite import the same real code. Tests now execute the shared utility directly rather than duplicating the filter expression — future route regressions would be caught.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
control-plane/src/domains/attestation/attestationService.ts (1)

30-40: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject invalid iat before resolving keys.

Line 33 creates a truthy Invalid Date for values such as "abc" or an out-of-range number, so it can reach getKeyByKid/getActiveKeysAt and escape as a database error instead of the required 400. Validate a finite timestamp and iat.getTime() before repository access.

Proposed fix
-  const iat = payload.iat ? new Date(payload.iat * 1000) : undefined;
+  const iatUnix = Number(payload.iat);
+  const iat = Number.isFinite(iatUnix)
+    ? new Date(iatUnix * 1000)
+    : undefined;
 
-  if (!iat) {
+  if (!iat || Number.isNaN(iat.getTime())) {
     throw new AppError(CODES.BAD_REQUEST, 'JWS missing iat claim');
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@control-plane/src/domains/attestation/attestationService.ts` around lines 30
- 40, Update the iat handling in the attestation parsing flow to reject
non-finite timestamps and Invalid Date values before calling getKeyByKid or
getActiveKeysAt. Preserve the existing 400 AppError for missing or invalid iat
claims, and only resolve repository keys after iat.getTime() is valid.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@control-plane/src/domains/attestation/attestationService.ts`:
- Around line 30-40: Update the iat handling in the attestation parsing flow to
reject non-finite timestamps and Invalid Date values before calling getKeyByKid
or getActiveKeysAt. Preserve the existing 400 AppError for missing or invalid
iat claims, and only resolve repository keys after iat.getTime() is valid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 830a8627-6f09-4c37-a1f3-5adf8637b984

📥 Commits

Reviewing files that changed from the base of the PR and between b5c4ab3 and 649a8a1.

📒 Files selected for processing (10)
  • control-plane/migrations/008_platform_role/migration.sql
  • control-plane/src/domains/attestation/attestationRepository.ts
  • control-plane/src/domains/attestation/attestationService.ts
  • control-plane/src/grpc/trustEngineClient.ts
  • control-plane/src/lib/tenantFilter.ts
  • control-plane/src/middleware/auth.ts
  • control-plane/src/middleware/requireScope.test.ts
  • control-plane/src/middleware/requireScope.ts
  • control-plane/src/routes/attestations.test.ts
  • control-plane/src/routes/attestations.ts

@messagesgoel-blip messagesgoel-blip left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of 649a8a1: review 4793336096 is resolved. attestations.ts and the tests now consume the same production resolveCallerTenantIds() implementation, and I found no new non-duplicate findings in this incremental change. Using the existing user.type discriminant instead of roles.length === 0 would still be preferable fail-closed hardening, but the current auth constructors maintain the required invariant, so I am not treating that as a blocker.

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