@openwop/openwop-conformance 2.42.9 → 2.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +3 -3
  3. package/coverage.md +1 -1
  4. package/dist/cli.js +15 -12
  5. package/dist/lib/certification-bundle-v3.js +19 -0
  6. package/dist/spec-artifacts.lock.json +2 -2
  7. package/fixtures/conformance-approval-reject-loopback.json +33 -0
  8. package/fixtures/conformance-approval-reject-routed.json +32 -0
  9. package/fixtures/conformance-approval-timeout-approve.json +46 -0
  10. package/fixtures/conformance-approval-timeout.json +28 -0
  11. package/fixtures.md +59 -2
  12. package/package.json +2 -2
  13. package/requirements.json +535 -38
  14. package/scenario-majors.json +20 -5
  15. package/schemas/CORPUS-STAMP.json +55 -54
  16. package/src/cli.ts +15 -12
  17. package/src/lib/auditIntegrity.ts +45 -0
  18. package/src/lib/certification-bundle-v3.ts +25 -0
  19. package/src/lib/standard-webhooks.ts +53 -0
  20. package/src/scenarios/agent-org-chart-scoping.test.ts +63 -0
  21. package/src/scenarios/audit-anomaly-shape.test.ts +100 -0
  22. package/src/scenarios/audit-checkpoint-signature.test.ts +14 -15
  23. package/src/scenarios/audit-log-integrity.test.ts +102 -46
  24. package/src/scenarios/byok-roundtrip.test.ts +21 -7
  25. package/src/scenarios/v2-agent-org-chart-served-shape.test.ts +63 -0
  26. package/src/scenarios/v2-approval-reject-disposition.test.ts +262 -0
  27. package/src/scenarios/v2-error-registry.test.ts +59 -0
  28. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +21 -9
  29. package/src/scenarios/v2-manifest-hatch-carried.test.ts +23 -7
  30. package/src/scenarios/v2-pack-isolation.test.ts +10 -6
  31. package/src/scenarios/v2-peer-dependency-declared.test.ts +18 -6
  32. package/src/scenarios/v2-registry-lifecycle.test.ts +232 -0
  33. package/src/scenarios/v2-webhook-secret-rotation.test.ts +28 -11
@@ -0,0 +1,100 @@
1
+ /**
2
+ * RFC 0218 §C — an audit anomaly's shape, server-free
3
+ * (`spec/v1/auth-profiles.md` §"Audit-log integrity" 4).
4
+ *
5
+ * A black-box caller cannot tamper with a host's audit log, so an untampered log
6
+ * serves no anomaly and `audit-log-integrity.test.ts` sees an empty array. This
7
+ * file holds the shape instead: one sample anomaly of every `kind` validates
8
+ * against `audit-verify-result.schema.json` (v1 and the derived v2 copy), and the
9
+ * shapes that must not are refused, the reference hosts' pre-§C
10
+ * `{ atSequence, kind, detail }` first among them. The host side is the tamper
11
+ * tests in openwop-examples, which assert every kind's member set.
12
+ *
13
+ * Sabotages, each of which turns a leg red: dropping the `checkpoint` conditional
14
+ * (a `merkle-mismatch` without it validates); dropping the `chain-break`
15
+ * conditional (a `chain-break` without its hashes validates); dropping the
16
+ * `else` arms (a mixed entry validates); dropping the root `chainValid`
17
+ * conditional (a forged-signature report with `chainValid: true` validates);
18
+ * admitting `atSequence` and dropping `atSeq` from `required` (the hosts' old
19
+ * hash-mismatch validates).
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import Ajv2020 from 'ajv/dist/2020.js';
26
+ import { SCHEMAS_DIR } from '../lib/paths.js';
27
+ import { req } from '../lib/requirement-ids.js';
28
+
29
+ const SPEC = 'RFC 0218 §C · spec/v1/auth-profiles.md §"Audit-log integrity" 4';
30
+ const SHAPE = 'openwop.requirement.0218.anomaly-shape';
31
+
32
+ const HASH_A = 'a'.repeat(64);
33
+ const HASH_B = 'b'.repeat(64);
34
+ const CHECKPOINT = { checkpoint: 'cp_5', atSequence: 5, merkleRoot: HASH_A, signature: 'c2ln' };
35
+
36
+ const result = (anomalies: object[], chainValid = anomalies.length === 0): object => ({
37
+ fromSeq: 1,
38
+ toSeq: 5,
39
+ chainValid,
40
+ checkpoints: [CHECKPOINT],
41
+ anomalies,
42
+ });
43
+
44
+ const VALID: Array<[string, object]> = [
45
+ ['a chain-break with both hashes', { atSeq: 4, kind: 'chain-break', expectedPrevHash: HASH_A, actualPrevHash: HASH_B, detail: 'prev_hash differs' }],
46
+ ['a chain-break at the genesis entry (null hashes)', { atSeq: 1, kind: 'chain-break', expectedPrevHash: null, actualPrevHash: HASH_B }],
47
+ ['a legacy entry without kind (a chain-break)', { atSeq: 4, expectedPrevHash: HASH_A, actualPrevHash: '' }],
48
+ ['a hash-mismatch', { atSeq: 3, kind: 'hash-mismatch', detail: 'recomputed != stored' }],
49
+ ['a missing-entry', { atSeq: 2, kind: 'missing-entry' }],
50
+ ['a merkle-mismatch with its checkpoint', { atSeq: 5, kind: 'merkle-mismatch', checkpoint: 'cp_5' }],
51
+ ['a signature-invalid with its checkpoint', { atSeq: 5, kind: 'signature-invalid', checkpoint: 'cp_5', detail: 'does not verify' }],
52
+ ];
53
+
54
+ const INVALID: Array<[string, object]> = [
55
+ // The reference hosts' pre-§C hash-mismatch, verbatim: wrong only in the
56
+ // sequence member's name, so nothing but `atSeq` can refuse it.
57
+ ['`atSequence` for `atSeq` (the reference hosts\' pre-§C shape)', { atSequence: 3, kind: 'hash-mismatch', detail: 'recomputed != stored' }],
58
+ ['a merkle-mismatch without `checkpoint`', { atSeq: 5, kind: 'merkle-mismatch' }],
59
+ ['a signature-invalid without `checkpoint`', { atSeq: 5, kind: 'signature-invalid', detail: 'x' }],
60
+ ['a chain-break without its hashes', { atSeq: 4, kind: 'chain-break', detail: 'x' }],
61
+ ['a legacy entry without kind or hashes', { atSeq: 4 }],
62
+ ['a mixed entry: a merkle-mismatch carrying the chain hashes', { atSeq: 5, kind: 'merkle-mismatch', checkpoint: 'cp_5', expectedPrevHash: HASH_A, actualPrevHash: HASH_B }],
63
+ ['a mixed entry: a chain-break carrying a checkpoint', { atSeq: 4, kind: 'chain-break', expectedPrevHash: HASH_A, actualPrevHash: HASH_B, checkpoint: 'cp_5' }],
64
+ ['a hash-mismatch carrying a checkpoint', { atSeq: 3, kind: 'hash-mismatch', checkpoint: 'cp_5' }],
65
+ ['an unknown kind', { atSeq: 3, kind: 'row-rewritten' }],
66
+ ];
67
+
68
+ const SCHEMAS = [['v1', 'audit-verify-result.schema.json'], ['v2', join('v2', 'audit-verify-result.schema.json')]].map(([label, file]) => {
69
+ const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, file!), 'utf8')) as object;
70
+ return { label: label!, validate: new Ajv2020({ strict: false, allErrors: true }).compile(schema) };
71
+ });
72
+
73
+ describe('RFC 0218 §C — audit anomaly shape (server-free, v1 and v2 schemas)', () => {
74
+ it('every anomaly kind validates in its own shape', () => {
75
+ for (const { label, validate } of SCHEMAS) {
76
+ for (const [name, anomaly] of VALID) {
77
+ const ok = validate(result([anomaly]));
78
+ expect(ok, req(SHAPE, SPEC, `${label}: ${name} validates: ${JSON.stringify(validate.errors)}`)).toBe(true);
79
+ }
80
+ expect(validate(result([])), req(SHAPE, SPEC, `${label}: an untampered result with no anomaly validates`)).toBe(true);
81
+ }
82
+ });
83
+
84
+ it('an anomaly outside its kind\'s shape is refused', () => {
85
+ for (const { label, validate } of SCHEMAS) {
86
+ for (const [name, anomaly] of INVALID) {
87
+ expect(validate(result([anomaly])), req(SHAPE, SPEC, `${label}: ${name} is refused`)).toBe(false);
88
+ }
89
+ }
90
+ });
91
+
92
+ it('chainValid is false exactly when anomalies is non-empty', () => {
93
+ const forged = { atSeq: 5, kind: 'signature-invalid', checkpoint: 'cp_5' };
94
+ for (const { label, validate } of SCHEMAS) {
95
+ expect(validate(result([forged], true)), req(SHAPE, SPEC, `${label}: chainValid: true beside a signature-invalid anomaly is refused`)).toBe(false);
96
+ expect(validate(result([], false)), req(SHAPE, SPEC, `${label}: chainValid: false with no anomaly is refused`)).toBe(false);
97
+ expect(validate(result([forged], false)), req(SHAPE, SPEC, `${label}: chainValid: false beside the anomaly validates`)).toBe(true);
98
+ }
99
+ });
100
+ });
@@ -2,8 +2,10 @@
2
2
  * RFC 0218 — a host's audit checkpoints are signed over their Merkle root
3
3
  * (`spec/v1/auth-profiles.md` §"Audit-log integrity" 3).
4
4
  *
5
- * Gated on the `openwop-audit-log-integrity` profile. Every checkpoint
6
- * `GET /v1/audit/verify` returns MUST verify under the advertised
5
+ * Gated on the `openwop-audit-log-integrity` profile at major 1 and on the
6
+ * `auditLogIntegrity` family at major 2 (RFC 0224; spec/v2/core/
7
+ * security-defaults.md §Audit-log integrity). Every checkpoint
8
+ * `GET /v1/audit/verify` (major 2: `GET /audit/verify`) returns MUST verify under the advertised
7
9
  * `checkpointPublicKey` as Ed25519 over its hex-decoded `merkleRoot`. The root
8
10
  * itself cannot be recomputed from outside (the entries are not on the wire),
9
11
  * so the black box witnesses the signature half only. The root half belongs to
@@ -15,38 +17,35 @@
15
17
 
16
18
  import { describe, it, expect } from 'vitest';
17
19
  import { driver } from '../lib/driver.js';
18
- import { capabilityFamily } from '../lib/discovery-capabilities.js';
19
20
  import { req } from '../lib/requirement-ids.js';
20
21
  import { softSkip } from '../lib/soft-skip.js';
21
22
  import { checkpointPublicKey, verifyCheckpointSignature } from '../lib/audit-checkpoint.js';
23
+ import { auditClaimName, auditIntegrityAdvert, auditVerifyPath } from '../lib/auditIntegrity.js';
22
24
 
23
- const SPEC = 'RFC 0218 · spec/v1/auth-profiles.md §"Audit-log integrity" 3';
25
+ const SPEC = 'RFC 0218 · spec/v1/auth-profiles.md §"Audit-log integrity" 3; spec/v2/core/security-defaults.md §"Audit-log integrity" (RFC 0224)';
24
26
  const SIGNED = 'openwop.requirement.0218.checkpoint-signature-over-root';
25
27
 
26
- interface AuditIntegrityCaps { checkpointPublicKey?: string }
27
- interface AuthCaps { profiles?: string[]; auditLogIntegrity?: AuditIntegrityCaps }
28
-
29
28
  describe('RFC 0218 — a host\'s checkpoints are signed over their root', () => {
30
29
  it('every checkpoint GET /v1/audit/verify returns verifies under the advertised key', async () => {
31
- const disco = await driver.get('/.well-known/openwop');
32
- const auth = capabilityFamily<AuthCaps>(disco.json, 'auth') ?? {};
33
- if (!(Array.isArray(auth.profiles) && auth.profiles.includes('openwop-audit-log-integrity'))) {
34
- return softSkip('inapplicable', 'the host does not claim openwop-audit-log-integrity');
30
+ const integrity = await auditIntegrityAdvert();
31
+ if (integrity === null) {
32
+ return softSkip('inapplicable', `the host does not claim ${auditClaimName()}`);
35
33
  }
36
- const advertised = auth.auditLogIntegrity?.checkpointPublicKey;
34
+ const advertised = integrity.checkpointPublicKey;
37
35
  if (typeof advertised !== 'string' || advertised.length === 0) {
38
36
  // A claimed profile without its key is the profile-shape leg's failure
39
37
  // (audit-log-integrity.test.ts); this row cannot be observed without it.
40
- return softSkip('blocked', 'the host claims the profile but advertises no auditLogIntegrity.checkpointPublicKey');
38
+ return softSkip('blocked', `the host claims ${auditClaimName()} but advertises no auditLogIntegrity.checkpointPublicKey`);
41
39
  }
42
40
  let key;
43
41
  try { key = checkpointPublicKey(advertised); } catch (e) {
44
42
  return softSkip('blocked', `auditLogIntegrity.checkpointPublicKey is not an Ed25519 SPKI key: ${(e as Error).message}`);
45
43
  }
46
44
 
47
- const verify = await driver.get('/v1/audit/verify?fromSeq=0&toSeq=1000000');
45
+ const path = auditVerifyPath(0, 1_000_000);
46
+ const verify = await driver.get(path);
48
47
  if (verify.status !== 200) {
49
- return softSkip('blocked', `GET /v1/audit/verify answered ${verify.status}; the verify leg of audit-log-integrity owns that contract`);
48
+ return softSkip('blocked', `GET ${path.split('?')[0]} answered ${verify.status}; the verify leg of audit-log-integrity owns that contract`);
50
49
  }
51
50
  const checkpoints = ((verify.json as { checkpoints?: Array<{ atSequence?: number; merkleRoot?: string; signature?: string }> }).checkpoints) ?? [];
52
51
  if (checkpoints.length === 0) {
@@ -1,11 +1,24 @@
1
1
  /**
2
- * Track 13: audit-log integrity profile (auth-profiles.md v1.1).
2
+ * Track 13: audit-log integrity profile (auth-profiles.md v1.1), and at major 2
3
+ * the `auditLogIntegrity` family (RFC 0224, spec/v2/core/security-defaults.md
4
+ * §Audit-log integrity).
3
5
  *
4
- * Verifies that hosts claiming the `openwop-audit-log-integrity` profile:
5
- * 1. Surface `capabilities.auth.auditLogIntegrity.hashChain: true`.
6
- * 2. Expose `GET /v1/audit/verify` which returns `{chainValid, checkpoints, anomalies}`.
6
+ * Verifies that hosts claiming audit-log integrity:
7
+ * 1. Advertise the facets a verifier needs — at major 1
8
+ * `capabilities.auth.auditLogIntegrity.hashChain: true` plus the key; at
9
+ * major 2 a record that validates against the generated family schema,
10
+ * whose `checkpointPublicKey` is an Ed25519 SPKI key.
11
+ * 2. Expose `GET /v1/audit/verify` (major 2: `GET /audit/verify`) which
12
+ * returns `{chainValid, checkpoints, anomalies}`.
7
13
  * 3. Report `chainValid: true` for an unmodified range.
8
14
  * 4. Surface at least one signed checkpoint with a non-empty `signature`.
15
+ * 5. List checkpoints ascending, no more than `checkpointIntervalEntries`
16
+ * apart (RFC 0224 §A.3). A host that omits a checkpoint from the verify
17
+ * body leaves a gap the advertised cadence forbids.
18
+ *
19
+ * Major 1 gates through behaviorGate (strict mode fails an unadvertised
20
+ * profile). Major 2 records `inapplicable` before any assertion when the
21
+ * family is absent — presence of the record is the claim.
9
22
  *
10
23
  * Tamper detection (mutating an entry then asserting `chainValid: false`)
11
24
  * requires admin access to the host's audit store and is NOT exercised
@@ -13,14 +26,18 @@
13
26
  * test for tamper detection — see auth-profiles.md.
14
27
  *
15
28
  * @see spec/v1/auth-profiles.md §"Audit-log integrity"
29
+ * @see spec/v2/core/security-defaults.md §"Audit-log integrity"
16
30
  */
17
31
 
18
32
  import { describe, it, expect } from 'vitest';
19
33
  import { driver } from '../lib/driver.js';
20
34
  import { behaviorGate } from '../lib/behavior-gate.js';
21
- import { capabilityFamily } from '../lib/discovery-capabilities.js';
22
35
  import { req } from '../lib/requirement-ids.js';
23
36
  import { softSkip } from '../lib/soft-skip.js';
37
+ import { targetMajor } from '../lib/seams.js';
38
+ import { v2RefValidator, v2Validator } from '../lib/v2.js';
39
+ import { checkpointPublicKey } from '../lib/audit-checkpoint.js';
40
+ import { AUDIT_PROFILE, auditClaimName, auditIntegrityAdvert, auditVerifyPath, type AuditIntegrityCaps } from '../lib/auditIntegrity.js';
24
41
  import Ajv2020 from 'ajv/dist/2020.js';
25
42
  import { readFileSync } from 'node:fs';
26
43
  import { join } from 'node:path';
@@ -31,67 +48,77 @@ import { SCHEMAS_DIR } from '../lib/paths.js';
31
48
  * is enforced. (Until 2026-09-27 the suite stripped them because the reference
32
49
  * postgres/sqlite hosts emitted a per-checkpoint `verified` bit the schema does
33
50
  * not name; the hosts now keep that bit host-internal. The aggregate verdict is
34
- * the schema's OPTIONAL `checkpointsValid`.)
51
+ * the schema's OPTIONAL `checkpointsValid`.) Major 2 validates against the v2 copy.
35
52
  */
36
53
  const VERIFY_RESULT_SCHEMA = JSON.parse(
37
54
  readFileSync(join(SCHEMAS_DIR, 'audit-verify-result.schema.json'), 'utf8'),
38
55
  ) as Record<string, unknown>;
39
56
 
40
- interface AuditIntegrityCaps {
41
- hashChain?: boolean;
42
- checkpointSignatureAlgorithm?: string;
43
- checkpointPublicKey?: string;
44
- checkpointIntervalEntries?: number;
45
- checkpointIntervalSeconds?: number;
46
- }
57
+ const CADENCE = 'openwop.requirement.0224.checkpoint-cadence';
47
58
 
48
- interface AuthCaps {
49
- profiles?: string[];
50
- auditLogIntegrity?: AuditIntegrityCaps;
59
+ /**
60
+ * The gate, per major. Major 1: behaviorGate on the profile (unchanged). Major 2:
61
+ * the family record or null. A null is recorded `inapplicable` by the caller
62
+ * before any assertion (`unclaimed()`).
63
+ */
64
+ async function gate(): Promise<AuditIntegrityCaps | null> {
65
+ const caps = await auditIntegrityAdvert();
66
+ if (targetMajor() === 2) return caps;
67
+ return behaviorGate(AUDIT_PROFILE, caps !== null) ? caps : null;
51
68
  }
52
69
 
53
- async function isProfileAdvertised(): Promise<boolean> {
54
- const disco = await driver.get('/.well-known/openwop');
55
- const auth = capabilityFamily<AuthCaps>(disco.json, 'auth') ?? {};
56
- return Array.isArray(auth.profiles) && auth.profiles.includes('openwop-audit-log-integrity');
70
+ const unclaimed = (): undefined => softSkip('inapplicable', `the host does not claim ${auditClaimName()} at major ${targetMajor()}`);
71
+
72
+ function validateVerifyBody(body: unknown): { ok: boolean; errors: string } {
73
+ if (targetMajor() === 2) return v2Validator('audit-verify-result')(body);
74
+ const validate = new Ajv2020({ strict: false, allErrors: true }).compile(VERIFY_RESULT_SCHEMA);
75
+ const ok = validate(body) as boolean;
76
+ return { ok, errors: JSON.stringify(validate.errors ?? []) };
57
77
  }
58
78
 
59
79
  describe('audit-log-integrity: profile shape', () => {
60
80
  it('host that claims the profile advertises required capability fields', async () => {
61
- if (!behaviorGate('openwop-audit-log-integrity', await isProfileAdvertised())) {
62
- return;
63
- }
64
-
65
- const disco = await driver.get('/.well-known/openwop');
66
- const integrity =
67
- capabilityFamily<AuthCaps>(disco.json, 'auth')
68
- ?.auditLogIntegrity ?? {};
81
+ const integrity = await gate();
82
+ if (integrity === null) return unclaimed();
69
83
 
70
- expect(integrity.hashChain, req('openwop.it.audit-log-integrity.host-that-claims-the-profile-advertises-required-capability-fields',
71
- 'auth-profiles.md §"Audit-log integrity"',
72
- "openwop-audit-log-integrity profile MUST advertise auditLogIntegrity.hashChain: true",
73
- )).toBe(true);
74
- expect(integrity.checkpointSignatureAlgorithm, req('openwop.it.audit-log-integrity.host-that-claims-the-profile-advertises-required-capability-fields',
75
- 'auth-profiles.md §"Audit-log integrity" §"Key management"',
76
- 'checkpointSignatureAlgorithm MUST be present (canonical: ed25519)',
77
- )).toBeDefined();
78
- expect(typeof integrity.checkpointPublicKey).toBe('string');
84
+ if (targetMajor() === 2) {
85
+ const shape = v2RefValidator('capabilities.schema.json#/properties/auditLogIntegrity')(integrity);
86
+ expect(shape.ok, req('openwop.it.audit-log-integrity.host-that-claims-the-profile-advertises-required-capability-fields',
87
+ 'security-defaults.md §"Audit-log integrity"',
88
+ `the auditLogIntegrity record MUST validate against the generated family schema: ${shape.errors}`,
89
+ )).toBe(true);
90
+ let keyOk = true;
91
+ try { checkpointPublicKey(String(integrity.checkpointPublicKey ?? '')); } catch { keyOk = false; }
92
+ expect(keyOk, req('openwop.it.audit-log-integrity.host-that-claims-the-profile-advertises-required-capability-fields',
93
+ 'security-defaults.md §"Audit-log integrity"',
94
+ 'auditLogIntegrity.checkpointPublicKey MUST be an Ed25519 SubjectPublicKeyInfo, base64',
95
+ )).toBe(true);
96
+ } else {
97
+ expect(integrity.hashChain, req('openwop.it.audit-log-integrity.host-that-claims-the-profile-advertises-required-capability-fields',
98
+ 'auth-profiles.md §"Audit-log integrity"',
99
+ "openwop-audit-log-integrity profile MUST advertise auditLogIntegrity.hashChain: true",
100
+ )).toBe(true);
101
+ expect(integrity.checkpointSignatureAlgorithm, req('openwop.it.audit-log-integrity.host-that-claims-the-profile-advertises-required-capability-fields',
102
+ 'auth-profiles.md §"Audit-log integrity" §"Key management"',
103
+ 'checkpointSignatureAlgorithm MUST be present (canonical: ed25519)',
104
+ )).toBeDefined();
105
+ expect(typeof integrity.checkpointPublicKey).toBe('string');
106
+ }
79
107
  });
80
108
  });
81
109
 
82
110
  describe('audit-log-integrity: verify endpoint returns chainValid', () => {
83
111
  it('GET /v1/audit/verify on a recent range reports chainValid: true', async () => {
84
- if (!behaviorGate('openwop-audit-log-integrity', await isProfileAdvertised())) {
85
- return;
86
- }
112
+ if ((await gate()) === null) return unclaimed();
87
113
 
88
- const verify = await driver.get('/v1/audit/verify?fromSeq=0&toSeq=100');
114
+ const path = auditVerifyPath(0, 100);
115
+ const verify = await driver.get(path);
89
116
  if (verify.status === 404) {
90
117
  // Host claims the profile but doesn't expose the endpoint — that's
91
118
  // a profile-claim violation. Fail explicitly.
92
119
  expect(verify.status, req('openwop.it.audit-log-integrity.get-v1-audit-verify-on-a-recent-range-reports-chainvalid-true',
93
120
  'auth-profiles.md §"Audit-log integrity" §"Verification endpoint"',
94
- 'claiming openwop-audit-log-integrity profile REQUIRES exposing GET /v1/audit/verify',
121
+ `claiming ${auditClaimName()} REQUIRES exposing GET ${path.split('?')[0]}`,
95
122
  )).not.toBe(404);
96
123
  return softSkip('blocked', 'precondition not met — `verify.status === 404` returned early (seam, prior step, or fixture unavailable)');
97
124
  }
@@ -111,11 +138,10 @@ describe('audit-log-integrity: verify endpoint returns chainValid', () => {
111
138
  // length > 0)`. The body is now validated against
112
139
  // audit-verify-result.schema.json (checkpoints[] + anomalies[] required;
113
140
  // each checkpoint needs checkpoint/atSequence/merkleRoot/signature).
114
- const validate = new Ajv2020({ strict: false, allErrors: true }).compile(VERIFY_RESULT_SCHEMA);
115
- const shapeOk = validate(body);
116
- expect(shapeOk, req('openwop.it.audit-log-integrity.get-v1-audit-verify-on-a-recent-range-reports-chainvalid-true',
141
+ const shape = validateVerifyBody(body);
142
+ expect(shape.ok, req('openwop.it.audit-log-integrity.get-v1-audit-verify-on-a-recent-range-reports-chainvalid-true',
117
143
  'audit-verify-result.schema.json',
118
- `GET /v1/audit/verify MUST return the AuditVerifyResult shape: ${JSON.stringify(validate.errors ?? [])}`,
144
+ `GET ${path.split('?')[0]} MUST return the AuditVerifyResult shape: ${shape.errors}`,
119
145
  )).toBe(true);
120
146
 
121
147
  expect(body.chainValid, req('openwop.it.audit-log-integrity.get-v1-audit-verify-on-a-recent-range-reports-chainvalid-true',
@@ -132,3 +158,33 @@ describe('audit-log-integrity: verify endpoint returns chainValid', () => {
132
158
  }
133
159
  });
134
160
  });
161
+
162
+ describe('audit-log-integrity: checkpoints at the advertised cadence', () => {
163
+ it('verify lists every checkpoint ascending, no more than checkpointIntervalEntries apart', async () => {
164
+ const integrity = await gate();
165
+ if (integrity === null) return unclaimed();
166
+ const every = integrity.checkpointIntervalEntries;
167
+ if (typeof every !== 'number' || !Number.isInteger(every) || every < 1) {
168
+ // Required at major 2 (the shape leg owns that failure); optional at major 1.
169
+ return softSkip(targetMajor() === 2 ? 'blocked' : 'inapplicable', 'checkpointIntervalEntries is not advertised as a positive integer, so no cadence is claimed');
170
+ }
171
+ const verify = await driver.get(auditVerifyPath(0, 1_000_000));
172
+ if (verify.status !== 200) {
173
+ return softSkip('blocked', `the verify endpoint answered ${verify.status}; the verify leg owns that contract`);
174
+ }
175
+ const seqs = (((verify.json as { checkpoints?: Array<{ atSequence?: unknown }> }).checkpoints) ?? []).map((c) => c.atSequence);
176
+ if (seqs.length === 0) {
177
+ return softSkip('inapplicable', 'the host has minted no checkpoint yet, so there is no cadence to observe');
178
+ }
179
+ let prev = 0;
180
+ for (const at of seqs) {
181
+ expect(typeof at === 'number' && Number.isInteger(at), req(CADENCE, 'security-defaults.md §"Audit-log integrity"', 'every checkpoint MUST carry an integer atSequence')).toBe(true);
182
+ const n = at as number;
183
+ expect(n >= prev, req(CADENCE, 'security-defaults.md §"Audit-log integrity"', `checkpoints MUST be listed ascending by atSequence: ${n} follows ${prev}`)).toBe(true);
184
+ expect(n - prev, req(CADENCE, 'security-defaults.md §"Audit-log integrity"',
185
+ `a checkpoint MUST anchor at most checkpointIntervalEntries (${every}) entries: checkpoint at ${n} follows ${prev === 0 ? 'the start of the log' : `the checkpoint at ${prev}`} — a checkpoint is missing from the verify body, or was never minted`,
186
+ )).toBeLessThanOrEqual(every);
187
+ prev = n;
188
+ }
189
+ });
190
+ });
@@ -6,12 +6,23 @@
6
6
  * asserts credentialRefs DO resolve and DO get used (positive test) —
7
7
  * with redaction-safe verification via SHA-256 hashing.
8
8
  *
9
- * Both scenarios skip trivially-pass when the host returns 404/422 from
10
- * the start-run call (production deployments don't advertise the
11
- * fixture surface). Hosts that opt into `OPENWOP_CONFORMANCE_FIXTURES=1`
9
+ * This is a FLOOR scenario (`openwop.floor.byok-roundtrip`), so a host that
10
+ * cannot run it has NOT witnessed the floor. When the canary fixture is not
11
+ * advertised, or the start-run call answers 404/422, every test records
12
+ * `blocked` with the reason — never a trivial pass. A production posture that
13
+ * keeps the `conformance.secret.echo` seam off therefore leaves its claimed
14
+ * `openwop-secrets` profile uncertified, and only that profile: the cut's
15
+ * other claims still certify. Hosts that opt into `OPENWOP_CONFORMANCE_FIXTURES=1`
12
16
  * AND pre-provision a secret under `openwop-conformance-canary-secret`
13
17
  * expose the surface and the scenarios run end-to-end.
14
18
  *
19
+ * Why not `describe.skipIf`: a file whose tests are all skipped WITHOUT a
20
+ * recorded reason is an unclassified return (RFC 0148 §A), and an
21
+ * unclassified FLOOR row in a claimed profile rejects the whole
22
+ * certification, not just that profile. Measured on openwop-app's
23
+ * 2026-09-28 post-deploy major-1 cut: 2,116 rows, `certified: none`, the
24
+ * only unclassified row being this file.
25
+ *
15
26
  * The scenarios assert shape + non-empty + redaction, not exact value
16
27
  * equality — any host-defined canary value works as long as it's
17
28
  * non-empty and the host's `secrets.resolve` returns it intact.
@@ -34,17 +45,18 @@ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
34
45
 
35
46
  const BYOK_WORKFLOW_ID = 'openwop-smoke-byok-roundtrip';
36
47
  const SKIP_NO_FIXTURE = !isFixtureAdvertised(BYOK_WORKFLOW_ID);
48
+ const NO_FIXTURE_REASON = `precondition not met — the \`${BYOK_WORKFLOW_ID}\` fixture is not advertised, so this host cannot witness the BYOK roundtrip floor (the \`conformance.secret.echo\` seam and the \`openwop-conformance-canary-secret\` canary are off)`;
37
49
 
38
- describe.skipIf(SKIP_NO_FIXTURE)('byok: end-to-end credentialRef resolution roundtrip (openwop-byok profile)', () => {
50
+ describe('byok: end-to-end credentialRef resolution roundtrip (openwop-byok profile)', () => {
39
51
  it('the canary fixture run MUST resolve a host-provisioned secret and emit SHA-256 hex', async () => {
52
+ if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
40
53
  const create = await driver.post('/v1/runs', {
41
54
  workflowId: 'openwop-smoke-byok-roundtrip',
42
55
  });
43
56
 
44
- // Fixture absent OR canary not provisioned — host doesn't opt in.
45
- // Scenario passes trivially.
57
+ // Fixture absent OR canary not provisioned: the floor is not witnessed.
46
58
  if (create.status === 404 || create.status === 422) {
47
- return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `create.status === 404 || create.status === 422` returned early (Fixture absent OR canary not provisioned — host doesn\'t opt in. Scenario passes trivially.)');
59
+ return softSkip('blocked', 'precondition not met — `create.status === 404 || create.status === 422` returned early (fixture advertised but the run could not start: canary not provisioned or seam unavailable)');
48
60
  }
49
61
 
50
62
  expect(create.status, req('openwop.it.byok-roundtrip.the-canary-fixture-run-must-resolve-a-host-provisioned-secret-and-emit-sha-256-h',
@@ -102,6 +114,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('byok: end-to-end credentialRef resolution roun
102
114
  });
103
115
 
104
116
  it('BYOK fixture run MUST emit a node.completed event for the resolve step', async () => {
117
+ if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
105
118
  const create = await driver.post('/v1/runs', {
106
119
  workflowId: 'openwop-smoke-byok-roundtrip',
107
120
  });
@@ -128,6 +141,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('byok: end-to-end credentialRef resolution roun
128
141
  });
129
142
 
130
143
  it('BYOK fixture run event log MUST NOT echo the resolved secret value (redaction)', async () => {
144
+ if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
131
145
  const create = await driver.post('/v1/runs', {
132
146
  workflowId: 'openwop-smoke-byok-roundtrip',
133
147
  });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * RFC 0087 §A/§D — the SERVED bodies of the org-chart read pair validate against
3
+ * `schemas/v2/agent-org-chart.schema.json` and
4
+ * `schemas/v2/org-chart-responsibility-view.schema.json`. Target major 2; the
5
+ * major-1 twin is the served-shape leg of `agent-org-chart-scoping.test.ts`.
6
+ *
7
+ * Why this exists: until 2026-09-28 only a server-free probe compiled the
8
+ * schema. openwop-app served its stored record (`{ tenantId, departments,
9
+ * members, updatedAt }` — `owner` missing, two extra keys on an
10
+ * `additionalProperties:false` object) on `GET /agents/org-chart` at both
11
+ * majors and nothing in the suite noticed (fixed in openwop-app #4189).
12
+ *
13
+ * Gate: the v2 `agents` record carries `orgChart` (presence is the claim,
14
+ * capabilities.md; there is no `supported` seat), else `inapplicable` — never a
15
+ * strict-mode failure. The department leg runs when the record carries
16
+ * `responsibilityView: true` and the chart has a department to read.
17
+ *
18
+ * @see api/v2/openapi.yaml `/agents/org-chart`, `/agents/org-chart/{departmentId}`
19
+ * @see RFCS/0087-agent-org-chart.md
20
+ */
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+ import { familyAdvertised, v2Validator } from '../lib/v2.js';
24
+ import { softSkip } from '../lib/soft-skip.js';
25
+ import { req } from '../lib/requirement-ids.js';
26
+
27
+ const ID = 'openwop.it.v2-agent-org-chart-served-shape.serves-the-org-chart-read-pair-in-the-exact-v2-schema-shape';
28
+
29
+ describe('RFC 0087 §A/§D — org-chart served shape (major 2, gated on agents.orgChart)', () => {
30
+ it('serves the org-chart read pair in the exact v2 schema shape', async () => {
31
+ const agents = await familyAdvertised('agents');
32
+ const orgChart = agents?.['orgChart'];
33
+ if (!orgChart || typeof orgChart !== 'object') return softSkip('inapplicable', 'agents.orgChart not advertised in the v2 discovery document');
34
+
35
+ const chartRes = await driver.get('/agents/org-chart');
36
+ expect(
37
+ chartRes.status,
38
+ req(ID, 'api/v2/openapi.yaml /agents/org-chart (RFC 0087 §D)', 'a host advertising agents.orgChart MUST serve GET /agents/org-chart with 200'),
39
+ ).toBe(200);
40
+ const chartCheck = v2Validator('agent-org-chart')(chartRes.json);
41
+ expect(
42
+ chartCheck.ok ? 'valid' : chartCheck.errors,
43
+ req(ID, 'schemas/v2/agent-org-chart.schema.json (RFC 0087 §A)', 'the served GET /agents/org-chart body MUST validate against agent-org-chart.schema.json (owner required; no extra keys)'),
44
+ ).toBe('valid');
45
+
46
+ const departments = (chartRes.json as { departments?: Array<{ departmentId?: unknown }> } | undefined)?.departments;
47
+ const probeDeptId = Array.isArray(departments) ? departments[0]?.departmentId : undefined;
48
+ // The view leg needs a department to read; an empty chart is still
49
+ // validated above (an empty chart is a conforming chart).
50
+ if ((orgChart as Record<string, unknown>)['responsibilityView'] === true && typeof probeDeptId === 'string') {
51
+ const view = await driver.get(`/agents/org-chart/${encodeURIComponent(probeDeptId)}`);
52
+ expect(
53
+ view.status,
54
+ req(ID, 'api/v2/openapi.yaml /agents/org-chart/{departmentId} (RFC 0087 §D)', "a host advertising agents.orgChart.responsibilityView MUST serve GET /agents/org-chart/{departmentId} with 200 for a department in the caller's chart"),
55
+ ).toBe(200);
56
+ const viewCheck = v2Validator('org-chart-responsibility-view')(view.json);
57
+ expect(
58
+ viewCheck.ok ? 'valid' : viewCheck.errors,
59
+ req(ID, 'schemas/v2/org-chart-responsibility-view.schema.json (RFC 0087 §D)', 'the served GET /agents/org-chart/{departmentId} body MUST validate against org-chart-responsibility-view.schema.json'),
60
+ ).toBe('valid');
61
+ }
62
+ });
63
+ });