From 9811ab7c7babec1d58afdb9d97aac6a1753ca66f Mon Sep 17 00:00:00 2001 From: globalmysterysnailrevolution Date: Thu, 24 Sep 2026 14:52:45 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec):=20checkProfileRegistration=20?= =?UTF-8?q?=E2=80=94=20what=20the=20gateway=20profile-registration=20route?= =?UTF-8?q?=20runs=20before=20inserting=20a=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcc-gateway stores an operator's measurement profile in its own append-only table keyed by (capabilityId, deviceId) (bus #2621), and composition's compiler takes the current row's digest from the server-re-read snapshot (bus #2238). This is the pure check the registration route calls; the route keeps authentication, the kernel-ownership predicate and storage. It refuses a profile that fails validation, names a different device than its row, is for another capability type, or has a term admission cannot evaluate (so every job under it would fail closed; better refused at registration than discovered at settlement). The digest is always computed server-side from the submitted profile; a client digest is only compared. Every problem is reported at once. unverifiableTerms in profile-admission.ts is exported as unverifiableProfileTerms so registration and admission share one definition. 11 tests; each of the five rules is mutation-checked. Spec 965/965, tsc clean, test files type-check. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PGXt1epJimheWaESMGBbXc --- .../__tests__/profile-registration.test.ts | 118 ++++++++++++++++++ packages/spec/src/evidence/index.ts | 3 + .../spec/src/evidence/profile-admission.ts | 10 +- .../spec/src/evidence/profile-registration.ts | 101 +++++++++++++++ 4 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/spec/src/__tests__/profile-registration.test.ts create mode 100644 packages/spec/src/evidence/profile-registration.ts diff --git a/packages/spec/src/__tests__/profile-registration.test.ts b/packages/spec/src/__tests__/profile-registration.test.ts new file mode 100644 index 000000000..a7e407686 --- /dev/null +++ b/packages/spec/src/__tests__/profile-registration.test.ts @@ -0,0 +1,118 @@ +/** + * checkProfileRegistration — what the gateway's append-only profile + * registration route (bus #2621) runs before inserting a row. + */ +import { describe, it, expect } from "vitest"; + +import { checkProfileRegistration } from "../evidence/profile-registration.js"; +import { computeMeasurementProfileDigest, type MeasurementProfileV1 } from "../evidence/measurement-profile.js"; + +const CAMERA = "dev-camera"; +const VERSION = "PhotoCameraAdapter-1.0.0"; + +function cameraProfile(): MeasurementProfileV1 { + return { + profileVersion: 1, + profileId: "pcc://profiles/test/inspected-page/v1", + outcome: { + capabilityType: "document-printing", + statement: "The page was printed and a separate camera inspected it.", + objectIdentity: { kind: "documentHash", value: "sha256:" + "c".repeat(64) }, + }, + device: { + deviceId: CAMERA, + kind: "camera", + adapterType: "photo", + permittedAdapterVersions: [VERSION], + permittedFirmwareVersions: [VERSION], + }, + measurement: { method: "optical-capture", quantity: "printed-page-image", unit: "none", sampling: { minSamples: 1 } }, + capture: { startCondition: "execution_completed", endCondition: "open", coverage: { policy: "one-shot", minFraction: 1 } }, + calibration: { required: false }, + interpretation: { evidenceTypeIds: ["capture.photo_nonced"], acceptanceLevel: "inspected_output", onDeviceFailure: "reject" }, + simulationProhibited: true, + witnesses: { requiredRoles: [], independentOfClaimant: false }, + onMissingData: "reject", + onContradiction: "reject", + }; +} + +const request = (profile: unknown, over: Partial<{ capabilityType: string; deviceId: string; claimedDigest: string }> = {}) => ({ + capabilityType: "document-printing", + deviceId: CAMERA, + profile, + ...over, +}); + +const codes = (r: ReturnType) => (r.ok ? [] : r.problems.map((p) => p.code)); + +describe("profile registration — accepts a registrable profile", () => { + it("returns the server-computed digest", () => { + const p = cameraProfile(); + const r = checkProfileRegistration(request(p)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.profileDigest).toBe(computeMeasurementProfileDigest(p)); + }); + + it("accepts a client digest only when it equals the server's", () => { + const p = cameraProfile(); + expect(checkProfileRegistration(request(p, { claimedDigest: computeMeasurementProfileDigest(p) })).ok).toBe(true); + }); +}); + +describe("profile registration — refuses what could never govern the device's evidence", () => { + it("an invalid profile", () => { + const p = { ...cameraProfile(), simulationProhibited: false }; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["profile-invalid"]); + }); + + it("a non-object never throws", () => { + expect(codes(checkProfileRegistration(request("not a profile")))).toEqual(["profile-invalid"]); + expect(codes(checkProfileRegistration(request(null)))).toEqual(["profile-invalid"]); + }); + + it("a profile naming another device than the row it would be stored under", () => { + expect(codes(checkProfileRegistration(request(cameraProfile(), { deviceId: "dev-other" })))).toEqual(["device-mismatch"]); + }); + + it("a profile for another capability type", () => { + expect(codes(checkProfileRegistration(request(cameraProfile(), { capabilityType: "cnc-milling" })))).toEqual([ + "capability-type-mismatch", + ]); + }); + + it("a profile with a term admission cannot evaluate (refused now, not discovered at settlement)", () => { + const p = cameraProfile(); + p.measurement.tolerance = { comparator: ">=", target: 0.9 }; + const r = checkProfileRegistration(request(p)); + expect(codes(r)).toEqual(["unverifiable-term"]); + }); + + it("a profile whose version pins can never both be met", () => { + const p = cameraProfile(); + p.device.permittedFirmwareVersions = ["fw-4.0.1"]; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["unverifiable-term"]); + }); + + it("a client digest that is not the digest of the submitted profile", () => { + const p = cameraProfile(); + const other = cameraProfile(); + other.measurement.sampling.minSamples = 2; + expect(codes(checkProfileRegistration(request(p, { claimedDigest: computeMeasurementProfileDigest(other) })))).toEqual([ + "digest-mismatch", + ]); + }); + + it("a client digest in the sha256: evidence-event family", () => { + const p = cameraProfile(); + const tagged = `sha256:${computeMeasurementProfileDigest(p).slice(2)}`; + expect(codes(checkProfileRegistration(request(p, { claimedDigest: tagged })))).toEqual(["digest-mismatch"]); + }); + + it("reports every problem at once, so an operator can fix the profile in one pass", () => { + const p = cameraProfile(); + p.measurement.tolerance = { comparator: ">=", target: 0.9 }; + const r = checkProfileRegistration(request(p, { deviceId: "dev-other", capabilityType: "cnc-milling", claimedDigest: "0x" + "0".repeat(64) })); + expect(codes(r)).toEqual(["device-mismatch", "capability-type-mismatch", "unverifiable-term", "digest-mismatch"]); + }); +}); diff --git a/packages/spec/src/evidence/index.ts b/packages/spec/src/evidence/index.ts index c921f0af6..eb19a798f 100644 --- a/packages/spec/src/evidence/index.ts +++ b/packages/spec/src/evidence/index.ts @@ -37,6 +37,9 @@ export * from "./measurement-profile.js"; // profileAdmitsBundle — does authenticated, bound evidence satisfy the committed // MeasurementProfile (level, device, version, window, samples, simulation)? export * from "./profile-admission.js"; +// checkProfileRegistration — may a MeasurementProfile be registered for a +// (capability, device)? Run by the gateway registration route. +export * from "./profile-registration.js"; export * from "./verifier-interface.js"; export * from "./verifiers/index.js"; export * from "./emitter-manifest.js"; diff --git a/packages/spec/src/evidence/profile-admission.ts b/packages/spec/src/evidence/profile-admission.ts index d22f06247..39807aa34 100644 --- a/packages/spec/src/evidence/profile-admission.ts +++ b/packages/spec/src/evidence/profile-admission.ts @@ -162,8 +162,12 @@ function result( const reject = (code: ProfileAdmissionCode, detail: string) => result("reject", [{ code, detail }]); -/** Terms of a valid profile this version cannot evaluate. */ -function unverifiableTerms(profile: MeasurementProfileV1): string[] { +/** + * Terms of a valid profile this version cannot evaluate. Registration refuses + * a profile with any of them (profile-registration.ts), so admission and + * registration share one definition. + */ +export function unverifiableProfileTerms(profile: MeasurementProfileV1): string[] { const terms: string[] = []; if (profile.measurement.tolerance !== undefined) { terms.push("measurement.tolerance: the profile does not name where the measured value lives"); @@ -233,7 +237,7 @@ export async function profileAdmitsBundle(input: ProfileAdmissionInput): Promise return reject(governance.code ?? "profile-invalid", governance.reasons.join("; ")); } - const terms = unverifiableTerms(profile); + const terms = unverifiableProfileTerms(profile); if (terms.length > 0) return reject("unverifiable-term", terms.join("; ")); if (!Array.isArray(bundles) || bundles.length === 0) { diff --git a/packages/spec/src/evidence/profile-registration.ts b/packages/spec/src/evidence/profile-registration.ts new file mode 100644 index 000000000..f2a361759 --- /dev/null +++ b/packages/spec/src/evidence/profile-registration.ts @@ -0,0 +1,101 @@ +/** + * checkProfileRegistration — may this MeasurementProfile be registered for a + * (capability, device)? + * + * pcc-gateway stores an operator's concrete profile in its own append-only + * table keyed by (capabilityId, deviceId) (bus #2621). The accepted-plan + * compiler later takes the CURRENT row's digest from the server-re-read + * provider snapshot (composition, bus #2238). This check is what the + * registration route runs before inserting a row. The route owns + * authentication, the kernel-ownership predicate and storage; this owns what + * makes a profile registrable: + * + * - `profile-invalid`: it fails `validateMeasurementProfile`; + * - `device-mismatch`: it names a different device from the row it would be + * stored under, so evidence from the registered device could never qualify; + * - `capability-type-mismatch`: its outcome is for another capability type; + * - `unverifiable-term`: admission cannot evaluate one of its terms, so every + * job under it would fail closed. Refused here rather than discovered at + * settlement; + * - `digest-mismatch`: the client sent a digest that is not the digest of + * the profile it sent. + * + * The digest is always computed here, from the submitted profile. A + * client-supplied digest is only ever compared, never stored or trusted. + * Every problem is reported, not just the first, so an operator can fix a + * profile in one pass. + */ + +import { + computeMeasurementProfileDigest, + validateMeasurementProfile, + type MeasurementProfileDigest, + type MeasurementProfileV1, +} from "./measurement-profile.js"; +import { unverifiableProfileTerms } from "./profile-admission.js"; + +export type ProfileRegistrationCode = + | "profile-invalid" + | "device-mismatch" + | "capability-type-mismatch" + | "unverifiable-term" + | "digest-mismatch"; + +export interface ProfileRegistrationProblem { + code: ProfileRegistrationCode; + detail: string; +} + +export interface ProfileRegistrationRequest { + /** The type of the capability the row belongs to (from the capability record, not the request). */ + capabilityType: string; + /** The device the row is keyed by. */ + deviceId: string; + /** The profile as submitted, unvalidated. */ + profile: unknown; + /** A digest the client computed, if it sent one. Compared, never trusted. */ + claimedDigest?: string; +} + +export type ProfileRegistrationResult = + | { ok: true; profile: MeasurementProfileV1; profileDigest: MeasurementProfileDigest } + | { ok: false; problems: ProfileRegistrationProblem[] }; + +/** Never throws. */ +export function checkProfileRegistration(request: ProfileRegistrationRequest): ProfileRegistrationResult { + const violations = validateMeasurementProfile(request.profile); + if (violations.length > 0) { + return { + ok: false, + problems: violations.map((v) => ({ code: "profile-invalid" as const, detail: `${v.path || ""}: ${v.message}` })), + }; + } + const profile = request.profile as MeasurementProfileV1; + const problems: ProfileRegistrationProblem[] = []; + + if (profile.device.deviceId !== request.deviceId) { + problems.push({ + code: "device-mismatch", + detail: `the profile names device ${JSON.stringify(profile.device.deviceId)} but would be registered for ${JSON.stringify(request.deviceId)}`, + }); + } + if (profile.outcome.capabilityType !== request.capabilityType) { + problems.push({ + code: "capability-type-mismatch", + detail: `the profile is for ${JSON.stringify(profile.outcome.capabilityType)} but the capability is ${JSON.stringify(request.capabilityType)}`, + }); + } + for (const term of unverifiableProfileTerms(profile)) { + problems.push({ code: "unverifiable-term", detail: term }); + } + + const profileDigest = computeMeasurementProfileDigest(profile); + if (request.claimedDigest !== undefined && request.claimedDigest !== profileDigest) { + problems.push({ + code: "digest-mismatch", + detail: `the client sent ${JSON.stringify(request.claimedDigest)}; the submitted profile digests to ${profileDigest}`, + }); + } + + return problems.length > 0 ? { ok: false, problems } : { ok: true, profile, profileDigest }; +} From 168ec19a399f5b6c4667243e894a86bce873577b Mon Sep 17 00:00:00 2001 From: globalmysterysnailrevolution Date: Mon, 28 Sep 2026 17:11:46 -0700 Subject: [PATCH 2/4] =?UTF-8?q?test(spec):=20registration=20follows=20#363?= =?UTF-8?q?=20round=202=20=E2=80=94=20split=20pins=20are=20registrable;=20?= =?UTF-8?q?new=20fail-closed=20terms=20and=20unknown=20keys=20are=20refuse?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #363 @48a69252 checks each version pin against its own evidence field (source.adapterVersion, source.firmwareVersion), so disjoint adapter and firmware lists are now registrable. The "pins can never both be met" test is replaced by a positive control. Registration refuses what admission would fail closed on: a pattern pin ("*"), a device kind no evidence source can carry, an inactive primitive id; and an unknown profile term is profile-invalid, so it can never be committed. Tests: registration 15 passed; spec 47 files, 1054 passed; tsc clean; the test files type-check. --- .../__tests__/profile-registration.test.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/__tests__/profile-registration.test.ts b/packages/spec/src/__tests__/profile-registration.test.ts index a7e407686..cd9633334 100644 --- a/packages/spec/src/__tests__/profile-registration.test.ts +++ b/packages/spec/src/__tests__/profile-registration.test.ts @@ -47,6 +47,12 @@ const request = (profile: unknown, over: Partial<{ capabilityType: string; devic const codes = (r: ReturnType) => (r.ok ? [] : r.problems.map((p) => p.code)); describe("profile registration — accepts a registrable profile", () => { + it("disjoint adapter and firmware pins are registrable: each pin is checked against its own evidence field", () => { + const p = cameraProfile(); + p.device.permittedFirmwareVersions = ["cam-fw-2.1.0"]; + expect(checkProfileRegistration(request(p)).ok).toBe(true); + }); + it("returns the server-computed digest", () => { const p = cameraProfile(); const r = checkProfileRegistration(request(p)); @@ -88,12 +94,31 @@ describe("profile registration — refuses what could never govern the device's expect(codes(r)).toEqual(["unverifiable-term"]); }); - it("a profile whose version pins can never both be met", () => { + it("a version pin written as a pattern (pins are exact strings)", () => { + const p = cameraProfile(); + p.device.permittedFirmwareVersions = ["*-unpinned-pilot"]; + const r = checkProfileRegistration(request(p)); + expect(codes(r)).toEqual(["unverifiable-term"]); + expect(r.ok ? "" : r.problems[0]!.detail).toContain("exact strings"); + }); + + it("a device kind no evidence source can carry", () => { const p = cameraProfile(); - p.device.permittedFirmwareVersions = ["fw-4.0.1"]; + p.device.kind = "machine"; expect(codes(checkProfileRegistration(request(p)))).toEqual(["unverifiable-term"]); }); + it("a primitive id that is not active in the vocabulary", () => { + const p = cameraProfile(); + p.interpretation.evidenceTypeIds = ["capture.no_such_primitive"]; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["unverifiable-term"]); + }); + + it("a profile with an unknown term is invalid, so it can never be committed", () => { + const p = { ...cameraProfile(), extraTerm: "never evaluated" }; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["profile-invalid"]); + }); + it("a client digest that is not the digest of the submitted profile", () => { const p = cameraProfile(); const other = cameraProfile(); From 492bd005248a00dc253a69c9c4b339bd4312a791 Mon Sep 17 00:00:00 2001 From: globalmysterysnailrevolution Date: Tue, 29 Sep 2026 09:52:49 -0700 Subject: [PATCH 3/4] fix(spec): profile registration checks, digests and returns one plain-data copy of the request The discipline astra required of admission (pack 39 round 2) applies here too. Registration validated request.profile and then read it again for the device, the capability type and the digest, so a getter or a caller mutation could split what was checked from what was stored. Every check now runs on one plainDataCopy of the request (each field read exactly once; anything JSON cannot carry is refused as profile-invalid), and the profile returned for storage is that copy. Tests: registration 18 passed (3 new: a copy is returned, a lying getter is read once, NaN is refused); spec 47 files, 1113 passed; tsc clean. --- .../__tests__/profile-registration.test.ts | 34 +++++++++++++++++++ .../spec/src/evidence/profile-registration.ts | 18 ++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/__tests__/profile-registration.test.ts b/packages/spec/src/__tests__/profile-registration.test.ts index cd9633334..06a74d966 100644 --- a/packages/spec/src/__tests__/profile-registration.test.ts +++ b/packages/spec/src/__tests__/profile-registration.test.ts @@ -141,3 +141,37 @@ describe("profile registration — refuses what could never govern the device's expect(codes(r)).toEqual(["device-mismatch", "capability-type-mismatch", "unverifiable-term", "digest-mismatch"]); }); }); + +describe("profile registration — checks, digest and stored row are one copy (astra pack 39 discipline)", () => { + it("the profile returned for storage is a copy, not the caller's object, and later mutation cannot reach it", () => { + const p = cameraProfile(); + const r = checkProfileRegistration(request(p)); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.profile).not.toBe(p); + p.device.deviceId = "dev-mutated-after-check"; + expect(r.profile.device.deviceId).toBe(CAMERA); + expect(r.profileDigest).toBe(computeMeasurementProfileDigest(cameraProfile())); + }); + + it("a getter that lies after the first read cannot split what is checked from what is stored", () => { + const p = cameraProfile(); + let reads = 0; + Object.defineProperty(p.device, "deviceId", { + enumerable: true, + configurable: true, + get: () => (reads++ === 0 ? CAMERA : "dev-other"), + }); + const r = checkProfileRegistration(request(p)); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.profile.device.deviceId).toBe(CAMERA); + expect(reads).toBe(1); + }); + + it("a request JSON cannot carry is refused as profile-invalid, never coerced", () => { + const p = cameraProfile() as unknown as Record; + (p.measurement as Record).sampling = { minSamples: Number.NaN }; + expect(codes(checkProfileRegistration(request(p)))).toEqual(["profile-invalid"]); + }); +}); diff --git a/packages/spec/src/evidence/profile-registration.ts b/packages/spec/src/evidence/profile-registration.ts index f2a361759..801557aab 100644 --- a/packages/spec/src/evidence/profile-registration.ts +++ b/packages/spec/src/evidence/profile-registration.ts @@ -20,14 +20,18 @@ * - `digest-mismatch`: the client sent a digest that is not the digest of * the profile it sent. * - * The digest is always computed here, from the submitted profile. A - * client-supplied digest is only ever compared, never stored or trusted. + * Every check runs on one plain-data copy of the request (`plainDataCopy`: + * each field read exactly once), and the profile returned for storage is that + * copy, so what is checked, digested and stored is one thing. The digest is + * always computed here, from that copy. A client-supplied digest is only ever + * compared, never stored or trusted. * Every problem is reported, not just the first, so an operator can fix a * profile in one pass. */ import { computeMeasurementProfileDigest, + plainDataCopy, validateMeasurementProfile, type MeasurementProfileDigest, type MeasurementProfileV1, @@ -62,7 +66,15 @@ export type ProfileRegistrationResult = | { ok: false; problems: ProfileRegistrationProblem[] }; /** Never throws. */ -export function checkProfileRegistration(request: ProfileRegistrationRequest): ProfileRegistrationResult { +export function checkProfileRegistration(input: ProfileRegistrationRequest): ProfileRegistrationResult { + const copy = plainDataCopy(input); + if (!copy.ok || typeof copy.value !== "object" || copy.value === null) { + return { + ok: false, + problems: [{ code: "profile-invalid", detail: `: the request is not plain JSON data (${copy.ok ? "not an object" : copy.reason})` }], + }; + } + const request = copy.value as ProfileRegistrationRequest; const violations = validateMeasurementProfile(request.profile); if (violations.length > 0) { return { From 760d84ebbd56496b6d4a45be08931fa0aafad9fa Mon Sep 17 00:00:00 2001 From: globalmysterysnailrevolution Date: Fri, 2 Oct 2026 19:37:08 -0700 Subject: [PATCH 4/4] test(spec): registration refuses a lying getter without reading it, per the plain-data boundary (astra pack 154) The merged plain-data boundary refuses accessors through their descriptors, so the getter that once ran exactly once now never runs, and the request is profile-invalid. This is the stronger property the old test approximated. --- .../spec/src/__tests__/profile-registration.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/__tests__/profile-registration.test.ts b/packages/spec/src/__tests__/profile-registration.test.ts index 06a74d966..234ae52a2 100644 --- a/packages/spec/src/__tests__/profile-registration.test.ts +++ b/packages/spec/src/__tests__/profile-registration.test.ts @@ -154,7 +154,10 @@ describe("profile registration — checks, digest and stored row are one copy (a expect(r.profileDigest).toBe(computeMeasurementProfileDigest(cameraProfile())); }); - it("a getter that lies after the first read cannot split what is checked from what is stored", () => { + // A getter could answer the check and the stored row differently, so the + // plain-data boundary refuses it without reading it (astra pack 154; it was + // once read exactly once, which still ran code supplied with the data). + it("a getter that could lie after the first read is refused, never read", () => { const p = cameraProfile(); let reads = 0; Object.defineProperty(p.device, "deviceId", { @@ -163,10 +166,8 @@ describe("profile registration — checks, digest and stored row are one copy (a get: () => (reads++ === 0 ? CAMERA : "dev-other"), }); const r = checkProfileRegistration(request(p)); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.profile.device.deviceId).toBe(CAMERA); - expect(reads).toBe(1); + expect(codes(r)).toEqual(["profile-invalid"]); + expect(reads).toBe(0); }); it("a request JSON cannot carry is refused as profile-invalid, never coerced", () => {