diff --git a/CHANGELOG.md b/CHANGELOG.md index 3939e0d..99e9b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ compatible, or editorial. ## Unreleased +- Clarified the unreleased Comic Manifest 1.0.0 candidate's (#95 prerequisite) + detached approval time bounds: decision time is inclusive, expiry is exclusive + at both action and scoped publication time. The test-only oracle preserves + submillisecond precision across variable fractional widths, accepts the full + schema timestamp separator forms, and validates trusted action calendar/clock + fields before comparison and fails closed on unsupported leap seconds. Synthetic + fixtures cover equal instants, expiry, future decisions and malformed action + times; no released contract bytes change. + - Extended the unreleased Comic Manifest 1.0.0 candidate for Studio #94 with immutable versioned output profiles, per-output limits, exact requirement/result/ release metadata matching and explicit rights/accessibility/publication boundaries. diff --git a/fixtures/comic-manifest-v1-approval-times.json b/fixtures/comic-manifest-v1-approval-times.json new file mode 100644 index 0000000..22aced5 --- /dev/null +++ b/fixtures/comic-manifest-v1-approval-times.json @@ -0,0 +1,215 @@ +[ + { + "name": "approval remains valid at decision time", + "action_time": "2026-09-15T12:02:00Z", + "publication_time": "2026-09-15T12:10:00Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:11:00Z", + "valid": true + }, + { + "name": "action just before expiry remains valid", + "action_time": "2026-09-15T12:10:00.000Z", + "publication_time": "2026-09-15T12:00:00Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00.001Z", + "valid": true + }, + { + "name": "publication just before expiry remains valid", + "action_time": "2026-09-15T12:05:00Z", + "publication_time": "2026-09-15T12:10:00Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00.001Z", + "valid": true + }, + { + "name": "action at expiry is expired", + "action_time": "2026-09-15T12:10:00Z", + "publication_time": "2026-09-15T12:09:59.999Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00Z", + "valid": false + }, + { + "name": "publication at expiry is expired", + "action_time": "2026-09-15T12:09:59.999Z", + "publication_time": "2026-09-15T12:10:00Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00Z", + "valid": false + }, + { + "name": "decision after action time is not yet valid", + "action_time": "2026-09-15T12:10:00Z", + "publication_time": "2026-09-15T12:10:00Z", + "decided_at": "2026-09-15T12:10:00.001Z", + "expires_at": "2026-09-15T12:11:00Z", + "valid": false + }, + { + "name": "submillisecond action just before expiry remains valid", + "action_time": "2026-09-15T12:10:00.0000Z", + "publication_time": "2026-09-15T12:10:00.0000Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00.0001Z", + "valid": true + }, + { + "name": "submillisecond publication just before expiry remains valid", + "action_time": "2026-09-15T12:05:00Z", + "publication_time": "2026-09-15T12:10:00.00010Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00.00011Z", + "valid": true + }, + { + "name": "equivalent fractional widths preserve inclusive decision equality", + "action_time": "2026-09-15T12:02:00.0001000Z", + "publication_time": "2026-09-15T12:10:00.00010Z", + "decided_at": "2026-09-15T12:02:00.0001Z", + "expires_at": "2026-09-15T12:10:00.00011Z", + "valid": true + }, + { + "name": "action at expiry is expired across fractional widths", + "action_time": "2026-09-15T12:10:00.0001000Z", + "publication_time": "2026-09-15T12:09:59.999Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00.0001Z", + "valid": false + }, + { + "name": "publication at expiry is expired across fractional widths", + "action_time": "2026-09-15T12:09:59.999999Z", + "publication_time": "2026-09-15T12:10:00.0001000Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15T12:10:00.0001Z", + "valid": false + }, + { + "name": "future submillisecond decision is rejected", + "action_time": "2026-09-15T12:10:00.0001Z", + "publication_time": "2026-09-15T12:10:00.0009Z", + "decided_at": "2026-09-15T12:10:00.0009Z", + "expires_at": "2026-09-15T12:10:00.0010Z", + "valid": false + }, + { + "name": "equivalent instants across accepted timestamp separators remain valid", + "action_time": "2026-09-15t12:10:00.0001000Z", + "schema_valid_action_time": true, + "publication_time": "2026-09-15\t12:10:00.00010Z", + "decided_at": "2026-09-15 12:10:00.0001Z", + "expires_at": "2026-09-15T12:10:00.00011Z", + "valid": true + }, + { + "name": "valid leap-day action time remains valid", + "action_time": "2024-02-29t12:10:00Z", + "schema_valid_action_time": true, + "publication_time": "2024-02-29 12:10:00Z", + "decided_at": "2024-02-29T12:02:00Z", + "expires_at": "2024-02-29T12:11:00Z", + "valid": true + }, + { + "name": "last ordinary second with maximum schema precision remains valid", + "action_time": "2026-09-15T23:59:59.99999999999Z", + "schema_valid_action_time": true, + "publication_time": "2026-09-15 23:59:59.99999999999Z", + "decided_at": "2026-09-15T23:59:59.99999999998Z", + "expires_at": "2026-09-16T00:00:00Z", + "valid": true + }, + { + "name": "action at expiry is expired across accepted timestamp separators", + "action_time": "2026-09-15 12:10:00.0001000Z", + "schema_valid_action_time": true, + "publication_time": "2026-09-15T12:09:59.999Z", + "decided_at": "2026-09-15t12:02:00Z", + "expires_at": "2026-09-15t12:10:00.0001Z", + "valid": false + }, + { + "name": "publication at expiry is expired across accepted timestamp separators", + "action_time": "2026-09-15T12:09:59.999999Z", + "publication_time": "2026-09-15t12:10:00.0001000Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-15 12:10:00.0001Z", + "valid": false + }, + { + "name": "invalid trusted action hour is rejected", + "action_time": "2026-09-15T24:00:00Z", + "publication_time": "2026-09-15T12:10:00Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-16T00:00:00Z", + "valid": false + }, + { + "name": "invalid trusted action minute is rejected", + "action_time": "2026-09-15T12:60:00Z", + "publication_time": "2026-09-15T12:10:00Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-16T00:00:00Z", + "valid": false + }, + { + "name": "invalid trusted action second is rejected", + "action_time": "2026-09-15T12:03:99Z", + "publication_time": "2026-09-15T12:10:00Z", + "decided_at": "2026-09-15T12:02:00Z", + "expires_at": "2026-09-16T00:00:00Z", + "valid": false + }, + { + "name": "invalid trusted action calendar date is rejected", + "action_time": "2026-02-30T12:10:00Z", + "publication_time": "2026-02-28T12:10:00Z", + "decided_at": "2026-02-28T12:02:00Z", + "expires_at": "2026-03-01T00:00:00Z", + "valid": false + }, + { + "name": "invalid non-leap-year February 29 action date is rejected", + "action_time": "2025-02-29T12:10:00Z", + "publication_time": "2025-02-28T12:10:00Z", + "decided_at": "2025-02-28T12:02:00Z", + "expires_at": "2025-03-01T00:00:00Z", + "valid": false + }, + { + "name": "schema-valid midmonth leap-second action time is unsupported", + "action_time": "2026-09-15T23:59:60Z", + "schema_valid_action_time": true, + "publication_time": "2026-09-15T23:59:59Z", + "decided_at": "2026-09-15T23:59:58Z", + "expires_at": "2026-09-16T00:00:00Z", + "valid": false + }, + { + "name": "schema-valid leap-second decision cannot authorize", + "action_time": "2026-09-16T00:00:00Z", + "publication_time": "2026-09-15T23:59:59Z", + "decided_at": "2026-09-15T23:59:60Z", + "expires_at": "2026-09-16T00:01:00Z", + "valid": false + }, + { + "name": "schema-valid leap-second expiry cannot grant authority", + "action_time": "2026-09-15T23:59:59Z", + "publication_time": "2026-09-15T23:59:59Z", + "decided_at": "2026-09-15T23:59:58Z", + "expires_at": "2026-09-15T23:59:60Z", + "valid": false + }, + { + "name": "schema-valid leap-second publication time cannot authorize", + "action_time": "2026-09-15T23:59:59Z", + "publication_time": "2026-09-15T23:59:60Z", + "decided_at": "2026-09-15T23:59:58Z", + "expires_at": "2026-09-16T00:01:00Z", + "valid": false + } +] diff --git a/fixtures/comic-manifest-v1.md b/fixtures/comic-manifest-v1.md index d75d5cf..15a3771 100644 --- a/fixtures/comic-manifest-v1.md +++ b/fixtures/comic-manifest-v1.md @@ -21,8 +21,17 @@ correction, partial/failed evidence, deterministic identity, exact versioned out profiles, inclusive pixel/byte boundaries, profile/media incompatibility, missing, duplicate and undeclared outputs, accessibility/rights metadata mismatch, unknown versions/fields, ordered identities, immutable references, package bindings, -stale/revoked/forged approvals, public gates and private-context projection. Tests -compose non-rendered image/PDF placeholder bytes to exercise declared profile +stale/revoked/forged approvals, approval-time boundaries, public gates and +private-context projection. The [approval-time fixture matrix](comic-manifest-v1-approval-times.json) +covers inclusive decision equality, just-before and equal expiry at both action +and publication time, future decisions, submillisecond boundaries, equal +instants with different fractional widths, and equivalent instants using the +uppercase `T`, lowercase `t`, or a single whitespace separator accepted by the +schema. Trusted action-time cases cover invalid hours, minutes, seconds and +calendar dates, valid leap-day and maximum-precision ordinary-second controls, +and fail-closed behavior for schema-valid second-60 timestamps in action, +decision, expiry and publication fields. +The tests compose non-rendered image/PDF placeholder bytes to exercise declared profile relationships; they deliberately do not claim media signatures or rendering. Output text bytes and public dependency bytes are explicit fixture inputs and independently hashed. Tests never create an approval from a production runtime or diff --git a/rfcs/0007-comic-manifest-v1.md b/rfcs/0007-comic-manifest-v1.md index 4e7cf17..bf2d9d0 100644 --- a/rfcs/0007-comic-manifest-v1.md +++ b/rfcs/0007-comic-manifest-v1.md @@ -15,6 +15,20 @@ approval bindings avoid circular hashes and preserve exact scope without grantin authority from schema validity. Public private-context lineage uses approved random attestations and protected mappings, not protected record IDs or hashes. +Trusted approval-time evaluation uses an inclusive decision-time bound and an +exclusive expiry bound: `decided_at <= action_time < expires_at`. The publication +time named in the exact scope must also be strictly earlier than `expires_at`. +Equality with `decided_at` is valid; equality with `expires_at` at either the +action or scoped publication time is expired. Before comparison, a verifier must +validate trusted `action_time` against the candidate schema's `$defs/time` +constraints, including calendar and clock ranges; invalid values must fail closed. +Approval-time comparisons support seconds `00` through `59`; a timestamp with +second `60` must fail closed for this check, even if its shape passes JSON Schema +date-time format validation. This deliberately does not infer leap-second dates. +Valid `T`, `t`, or single-whitespace separators and fractional seconds remain +accepted as defined by that schema. This records the existing test-oracle +relationship as an explicit contract rule. + Codex owns normative meaning. Platform validates and supplies a test-only consumer proof against the existing released SDK/Builder output. Universe owns public publication and canon; private source owners retain access/disclosure authority. diff --git a/specs/manifests/comic-manifest-v1.md b/specs/manifests/comic-manifest-v1.md index 538d465..24eae8d 100644 --- a/specs/manifests/comic-manifest-v1.md +++ b/specs/manifests/comic-manifest-v1.md @@ -266,8 +266,23 @@ raw artifact digests (empty for production review), exact release `scope`, revie role/actor, decision UUID, UTC decision/expiry and external authority reference. A binding is necessary evidence, not an authenticated grant. Trusted verification MUST bind the actual reviewer to the role, subject, scope and decision, check -revocation and current time, and fail closed on missing evidence. It MUST be valid -at the actual action and authorized publication time. Never trust an allow boolean. +revocation and current time, and fail closed on missing evidence. Let `action_time` +be the trusted time at which the authorization-dependent action is evaluated. The +verifier MUST validate `action_time` against the candidate schema's `$defs/time` +constraints, including calendar and clock ranges, before comparison. Invalid +calendar dates and out-of-range clock fields MUST fail closed. Approval-time +comparisons support seconds `00` through `59`; a timestamp with second `60` MUST +fail closed for this check, even if its shape passes JSON Schema date-time format +validation. This deliberately does not infer leap-second dates. Valid `T`, `t`, or +single-whitespace separators and fractional seconds remain accepted as defined by +the schema. The time check MUST satisfy `decided_at <= action_time < expires_at`; it MUST also +satisfy `scope.publication_time < expires_at` for the publication time named in the +exact scope. Expiry is exclusive: an action or scoped publication at exactly +`expires_at` is expired, while an action at exactly `decided_at` is valid. A +decision later than `action_time` is not yet valid. The publication-time check +continues to use the existing scope relationship; this clarification adds no +separate ordering rule between `decided_at` and `scope.publication_time`. Never +trust an allow boolean. The production-reviewer approves the exact production intent for its declared scope. Publisher and canon-editor decisions bind the exact public release payload diff --git a/tests/comic-manifest.test.cjs b/tests/comic-manifest.test.cjs index 6d0358d..6bf8520 100644 --- a/tests/comic-manifest.test.cjs +++ b/tests/comic-manifest.test.cjs @@ -9,7 +9,9 @@ const read = path => JSON.parse(readFileSync(resolve(__dirname, '..', path))); const schema = read('schemas/json/comic-manifest/v1/comic-manifest.schema.json'); const ajv = new Ajv({ strict: true, strictRequired: false, strictTypes: false, allErrors: true }); addFormats(ajv); const validate = ajv.compile(schema), approval = ajv.compile({ $ref: schema.$id + '#/$defs/approval' }); +const timestamp = ajv.compile(schema.$defs.time); const base = read('fixtures/comic-manifest-v1.json'), foundation = read('fixtures/context-builder-v1.json'); +const approvalTimeCases = read('fixtures/comic-manifest-v1-approval-times.json'); function schemaValid(s) { for (const k of ['production','result','release']) assert.equal(validate(s[k]), true, `${k}: ${JSON.stringify(validate.errors)}`); for (const a of s.approvals) assert.equal(approval(a), true, JSON.stringify(approval.errors)); @@ -73,6 +75,22 @@ test('all detached approval fields are closed and exactly versioned', () => { assert.equal(approval({...base.approvals[0],allow:true}),false); assert.equal(approval({...base.approvals[0],spec_version:'2.0.0'}),false); }); +for (const c of approvalTimeCases) test(c.name, () => { + const s = structuredClone(base); + s.at = c.action_time; + if (c.schema_valid_action_time !== undefined) assert.equal(timestamp(s.at), c.schema_valid_action_time); + s.release.scope.publication_time = c.publication_time; + for (const a of s.approvals) { + a.decided_at = c.decided_at; + a.expires_at = c.expires_at; + } + s.release.approvers = s.approvals.filter(a => a.role !== 'production-reviewer') + .map(({ decision_id, role, actor, decided_at }) => ({ decision_id, role, actor, decided_at })); + rebind(s); + schemaValid(s); + if (c.valid) verifyScenario(s, foundation); + else assert.throws(() => verifyScenario(s, foundation), /APPROVAL_TIME/); +}); for (const c of read('fixtures/comic-manifest-v1-cases.json')) test(c.name, () => { const s = structuredClone(base); for (const edit of c.edits) { diff --git a/tests/support/comic-manifest-oracle.cjs b/tests/support/comic-manifest-oracle.cjs index 910c459..f02f738 100644 --- a/tests/support/comic-manifest-oracle.cjs +++ b/tests/support/comic-manifest-oracle.cjs @@ -7,6 +7,30 @@ const check = (v, code) => assert.ok(v, code); const unique = (xs, code) => eq(new Set(xs).size, xs.length, code); const rank = c => ['public', 'internal', 'confidential', 'restricted'].indexOf(c); const gates = ['editorial', 'canon-continuity', 'visual-text', 'integrity', 'provenance', 'security-privacy', 'rights', 'accessibility', 'packaging']; +function parseUtcInstant(value) { + if (typeof value !== 'string' || value.length > 32) return null; + const match = /^(\d{4})-(\d{2})-(\d{2})[Tt\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?Z$/.exec(value); + if (!match) return null; + const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction] = match; + const year = Number(yearText), month = Number(monthText), day = Number(dayText); + const hour = Number(hourText), minute = Number(minuteText), second = Number(secondText); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [0, 31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (month < 1 || month > 12 || day < 1 || day > days[month]) return null; + if (hour > 23 || minute > 59 || second > 59) return null; + return { + wholeSecond: `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}`, + fraction: fraction ?? '' + }; +} +function compareUtcInstants(left, right) { + const a = parseUtcInstant(left), b = parseUtcInstant(right); + check(a && b, 'APPROVAL_TIME'); + if (a.wholeSecond !== b.wholeSecond) return a.wholeSecond < b.wholeSecond ? -1 : 1; + const width = Math.max(a.fraction.length, b.fraction.length); + const af = a.fraction.padEnd(width, '0'), bf = b.fraction.padEnd(width, '0'); + return af < bf ? -1 : af > bf ? 1 : 0; +} const renditionProfiles = { 'comic-page-image@1.0.0': { media: ['image/png', 'image/jpeg', 'image/webp'], maxBytes: 50_000_000, image: true }, 'comic-portable-document@1.0.0': { media: ['application/pdf'], maxBytes: 50_000_000, image: false }, @@ -96,8 +120,9 @@ function verifyApproval(a, subject, artifacts, scope, s) { eq(a.subject, identity(subject), 'APPROVAL_SUBJECT'); eq(a.artifact_digests, artifacts.map(x => x.artifact.sha256).sort(), 'APPROVAL_ARTIFACTS'); eq(a.scope, scope, 'APPROVAL_SCOPE'); - const at = Date.parse(s.at); - check(Date.parse(a.decided_at) <= at && at < Date.parse(a.expires_at) && Date.parse(scope.publication_time) < Date.parse(a.expires_at), 'APPROVAL_TIME'); + // Validate trusted action time before comparing arbitrary schema-valid fractions. + check(compareUtcInstants(a.decided_at, s.at) <= 0 && compareUtcInstants(s.at, a.expires_at) < 0 && + compareUtcInstants(scope.publication_time, a.expires_at) < 0, 'APPROVAL_TIME'); // A test harness supplies trusted decisions OUTSIDE untrusted record bytes. const trusted = s.trust.approvals.find(x => x.decision_id === a.decision_id); check(trusted && trusted.sha256 === identity(a).sha256 && !s.trust.revoked.includes(a.decision_id), 'APPROVAL_TRUST');