rcf-lite 0.25.0 → 0.26.0

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 (137) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/blueprints/application-account-settings/docs/topics.md +1 -1
  3. package/blueprints/application-admin-console/docs/topics.md +1 -1
  4. package/blueprints/application-api-rest/docs/topics.md +1 -1
  5. package/blueprints/application-charts/docs/topics.md +1 -1
  6. package/blueprints/application-dashboard/docs/topics.md +1 -1
  7. package/blueprints/application-datatable/docs/topics.md +1 -1
  8. package/blueprints/application-empty-error-states/docs/topics.md +1 -1
  9. package/blueprints/application-error-handling/docs/topics.md +1 -1
  10. package/blueprints/application-file-upload/docs/topics.md +1 -1
  11. package/blueprints/application-forms-wizard/docs/topics.md +1 -1
  12. package/blueprints/application-notifications-in-app/docs/topics.md +1 -1
  13. package/blueprints/application-onboarding-tour/docs/topics.md +1 -1
  14. package/blueprints/application-spa/docs/topics.md +1 -1
  15. package/blueprints/delivery-ci-workflows/docs/topics.md +1 -1
  16. package/blueprints/deploy-cloudflare-workers/docs/topics.md +1 -1
  17. package/blueprints/deploy-hetzner-server/CHANGELOG.md +21 -0
  18. package/blueprints/deploy-hetzner-server/blueprint.json +1 -1
  19. package/blueprints/deploy-hetzner-server/contributions/probes/cloud-init-render-lint.mjs +12 -5
  20. package/blueprints/deploy-hetzner-server/contributions/probes/hcloud-dry-run-mock.mjs +68 -20
  21. package/blueprints/deploy-hetzner-server/contributions/probes/manifest-schema-validate.mjs +8 -6
  22. package/blueprints/deploy-hetzner-server/contributions/probes/probe-utils.mjs +7 -1
  23. package/blueprints/deploy-hetzner-server/contributions/probes/real-account-cloud-init-hardened.mjs +2 -1
  24. package/blueprints/deploy-hetzner-server/contributions/templates/cloud-init.yaml.tmpl +26 -6
  25. package/blueprints/deploy-hetzner-server/docs/topics.md +1 -1
  26. package/blueprints/edge-cloudflare-access/docs/topics.md +1 -1
  27. package/blueprints/edge-cloudflare-rate-limiting/docs/topics.md +1 -1
  28. package/blueprints/edge-cloudflare-tunnel/CHANGELOG.md +10 -0
  29. package/blueprints/edge-cloudflare-tunnel/blueprint.json +1 -1
  30. package/blueprints/edge-cloudflare-tunnel/contributions/probes/manifest-schema-validate.mjs +4 -1
  31. package/blueprints/edge-cloudflare-tunnel/contributions/probes/real-account-connector-healthy.mjs +110 -21
  32. package/blueprints/edge-cloudflare-tunnel/contributions/probes/real-account-tunnel-hostname-routes.mjs +123 -30
  33. package/blueprints/edge-cloudflare-tunnel/docs/topics.md +1 -1
  34. package/blueprints/edge-cloudflare-turnstile/docs/topics.md +1 -1
  35. package/blueprints/email-smtp-resend/docs/topics.md +1 -1
  36. package/blueprints/jobs-background/docs/topics.md +1 -1
  37. package/blueprints/messaging-queue-cloudflare/CHANGELOG.md +19 -0
  38. package/blueprints/messaging-queue-cloudflare/README.md +2 -2
  39. package/blueprints/messaging-queue-cloudflare/blueprint.json +1 -1
  40. package/blueprints/messaging-queue-cloudflare/contributions/probes/real-account-concurrency-smoke.mjs +192 -37
  41. package/blueprints/messaging-queue-cloudflare/docs/topics.md +1 -1
  42. package/blueprints/object-storage-s3/CHANGELOG.md +11 -0
  43. package/blueprints/object-storage-s3/README.md +14 -1
  44. package/blueprints/object-storage-s3/blueprint.json +12 -1
  45. package/blueprints/object-storage-s3/contributions/adrs/adr-2905-object-storage-s3-hetzner-object-storage-provider.json +30 -0
  46. package/blueprints/object-storage-s3/contributions/probes/hetzner-object-storage-round-trip.mjs +136 -0
  47. package/blueprints/object-storage-s3/contributions/probes/run-hetzner-object-storage-round-trip.mjs +11 -0
  48. package/blueprints/object-storage-s3/contributions/requirements/object-storage-s3-req-101.json +18 -0
  49. package/blueprints/object-storage-s3/contributions/tacs/tac-2904-object-storage-s3-hetzner-endpoint-helper.json +34 -0
  50. package/blueprints/object-storage-s3/contributions/user-stories/object-storage-s3-us-28110.json +25 -0
  51. package/blueprints/object-storage-s3/docs/topics.md +1 -1
  52. package/blueprints/object-storage-s3/guide/object-storage-s3.md +36 -0
  53. package/blueprints/observability-essentials/docs/topics.md +1 -1
  54. package/blueprints/observability-logging/docs/topics.md +1 -1
  55. package/blueprints/observability-probe-endpoints/docs/topics.md +1 -1
  56. package/blueprints/persistence-data-d1/docs/topics.md +1 -1
  57. package/blueprints/persistence-data-postgres/docs/topics.md +1 -1
  58. package/blueprints/persistence-data-sqlite/docs/topics.md +1 -1
  59. package/blueprints/platform-cloudflare-cron-triggers/docs/topics.md +1 -1
  60. package/blueprints/platform-cloudflare-durable-objects/CHANGELOG.md +15 -0
  61. package/blueprints/platform-cloudflare-durable-objects/blueprint.json +1 -1
  62. package/blueprints/platform-cloudflare-durable-objects/contributions/probes/namespace-facade-ready.mjs +3 -3
  63. package/blueprints/platform-cloudflare-durable-objects/contributions/probes/probe-utils.mjs +28 -14
  64. package/blueprints/platform-cloudflare-durable-objects/contributions/probes/real-account-storage-smoke.mjs +132 -26
  65. package/blueprints/platform-cloudflare-durable-objects/contributions/probes/sole-reader-scan.mjs +40 -39
  66. package/blueprints/platform-cloudflare-durable-objects/contributions/probes/storage-round-trip.mjs +26 -16
  67. package/blueprints/platform-cloudflare-durable-objects/contributions/probes/websocket-hub-broadcast.mjs +31 -34
  68. package/blueprints/platform-cloudflare-durable-objects/docs/topics.md +1 -1
  69. package/blueprints/platform-cloudflare-kv/CHANGELOG.md +40 -1
  70. package/blueprints/platform-cloudflare-kv/README.md +41 -13
  71. package/blueprints/platform-cloudflare-kv/blueprint.json +1 -1
  72. package/blueprints/platform-cloudflare-kv/contributions/probes/cache-aside-hit-then-miss.mjs +26 -26
  73. package/blueprints/platform-cloudflare-kv/contributions/probes/event-secrecy.mjs +49 -31
  74. package/blueprints/platform-cloudflare-kv/contributions/probes/facade-round-trip.mjs +58 -16
  75. package/blueprints/platform-cloudflare-kv/contributions/probes/list-with-prefix.mjs +6 -5
  76. package/blueprints/platform-cloudflare-kv/contributions/probes/real-account-eventual-consistency-smoke.mjs +131 -67
  77. package/blueprints/platform-cloudflare-kv/docs/topics.md +1 -1
  78. package/blueprints/platform-docker-compose-host/docs/topics.md +1 -1
  79. package/blueprints/security-auth-clerk/docs/topics.md +1 -1
  80. package/blueprints/security-auth-keycloak/docs/topics.md +1 -1
  81. package/blueprints/security-auth-magic-link/docs/topics.md +1 -1
  82. package/blueprints/security-auth-oauth2/docs/topics.md +1 -1
  83. package/blueprints/security-secrets-management/docs/topics.md +1 -1
  84. package/fixtures/canary-manifest.json +9 -9
  85. package/guidance/harness-template.md +67 -0
  86. package/guidance/managed/agent-instructions-block.hash +1 -1
  87. package/guidance/managed/agent-instructions-block.md +67 -0
  88. package/package.json +2 -2
  89. package/rcf/code-nodes/cn-500.json +12 -0
  90. package/rcf/code-nodes/cn-510.json +12 -0
  91. package/rcf/code-nodes/cn-520.json +14 -0
  92. package/rcf/code-nodes/cn-521.json +12 -0
  93. package/rcf/code-nodes/cn-522.json +13 -0
  94. package/rcf/code-nodes/cn-523.json +12 -0
  95. package/rcf/code-nodes/cn-524.json +14 -0
  96. package/rcf/code-nodes/cn-525.json +12 -0
  97. package/rcf/code-nodes/cn-526.json +14 -0
  98. package/rcf/fbs/fbs-160.json +15 -0
  99. package/rcf/fbs/fbs-165.json +15 -0
  100. package/rcf/fbs/fbs-170.json +16 -0
  101. package/rcf/fbs/fbs-171.json +16 -0
  102. package/rcf/fbs/fbs-172.json +15 -0
  103. package/rcf/fbs/fbs-173.json +17 -0
  104. package/rcf/fbs/fbs-174.json +17 -0
  105. package/rcf/requirements/req-140.json +149 -0
  106. package/rcf/requirements/req-145.json +53 -0
  107. package/rcf/requirements/req-150.json +20 -0
  108. package/rcf/requirements/req-151.json +85 -0
  109. package/rcf/requirements/req-152.json +20 -0
  110. package/rcf/requirements/req-153.json +59 -0
  111. package/rcf/requirements/req-154.json +44 -0
  112. package/rcf/test-suites/ts-071.json +2 -2
  113. package/rcf/test-suites/ts-086.json +2 -2
  114. package/rcf/test-suites/ts-170.json +22 -0
  115. package/rcf/test-suites/ts-175.json +22 -0
  116. package/rcf/test-suites/ts-180.json +30 -0
  117. package/rcf/test-suites/ts-181.json +30 -0
  118. package/rcf/test-suites/ts-182.json +22 -0
  119. package/rcf/test-suites/ts-183.json +38 -0
  120. package/rcf/test-suites/ts-184.json +38 -0
  121. package/rcf/user-stories/us-14001.json +21 -0
  122. package/rcf/user-stories/us-14501.json +24 -0
  123. package/rcf/user-stories/us-15001.json +26 -0
  124. package/rcf/user-stories/us-15101.json +26 -0
  125. package/rcf/user-stories/us-15201.json +21 -0
  126. package/rcf/user-stories/us-15301.json +31 -0
  127. package/rcf/user-stories/us-15401.json +31 -0
  128. package/releases/releases.yaml +11 -1
  129. package/src/blueprint/apply.js +64 -0
  130. package/src/blueprint/consistency-lint.js +432 -0
  131. package/src/blueprint/disposition-ledger.js +224 -0
  132. package/src/blueprint/dispositions.js +44 -0
  133. package/src/blueprint/index.js +19 -0
  134. package/src/browser-verify/manifest-writer.js +53 -2
  135. package/src/browser-verify/pack-runner.js +18 -0
  136. package/src/cli/blueprint.js +103 -0
  137. package/src/setup/identity-seed.js +18 -0
@@ -0,0 +1,31 @@
1
+ {
2
+ "createdAt": "2026-09-08T12:17:35.296Z",
3
+ "updatedAt": "2026-09-08T12:19:48.200Z",
4
+ "asA": "rcf-lite maintainer applying edge-cloudflare-tunnel v1.0.0",
5
+ "iWant": "real-account-connector-healthy to actually provision the throwaway hetzner server through the T-1 fixture surface and run cloudflared tunnel info <name> asserting healthy connector count (when both CI_HAS_CLOUDFLARE_ACCOUNT and CI_HAS_HETZNER_ACCOUNT are set), real-account-tunnel-hostname-routes to drive undici for the public-hostname sub-case (activated on CI_HAS_CLOUDFLARE_ACCOUNT) and a two-identity JWT probe for the access-gated sub-case (activated only when CI_HAS_CLOUDFLARE_ACCESS is also set), and manifest-schema-validate to fail with a fixture-setup pointer when the credentials placeholder is missing rather than warn",
6
+ "soThat": "the real-account tunnel run proves connectors are up and routes actually serve on Cloudflare, the warn-on-env-set branches never again mask a broken driver, and a missing fixture pointer surfaces as a clear failure not a warning",
7
+ "description": "Part of H-2. connector-healthy consumes the hetzner fixture as-is; its defects are H-1 scope at HQ; if the fixture path fails locally the driver fails with a pointer naming the fixture step, never warn. hostname-routes: the public-hostname sub-case fetches the scratch subdomain via undici; the access-gated sub-case obtains an Access Service Token identity, exchanges it for a JWT and asserts an authenticated fetch succeeds AND an unauthenticated fetch is denied; the AUD sub-case stays pass-with-skip when CI_HAS_CLOUDFLARE_ACCESS is unset (HQ note: Access is not enabled on the HQ account today, so at gate time the AUD sub-case stays pass-with-skip until HQ enables Access, expected not a driver defect). Env-absent branch keeps pass-with-skip on both probes; a pass must never again be reachable from account-flag presence alone.",
8
+ "usId": "US-15301",
9
+ "prdId": "PRD-001",
10
+ "reqId": "REQ-153",
11
+ "version": "0.1.0",
12
+ "status": "draft",
13
+ "title": "H-2 tunnel real drivers: connector-healthy exercises cloudflared tunnel info against the throwaway hetzner server; hostname-routes drives undici and (when Access is enabled) a two-identity JWT probe; manifest-schema-validate placeholder becomes fail-with-pointer",
14
+ "acceptanceCriteria": [
15
+ {
16
+ "id": "AC-15301-1",
17
+ "description": "When both CI_HAS_CLOUDFLARE_ACCOUNT and CI_HAS_HETZNER_ACCOUNT are set with the required credentials, real-account-connector-healthy provisions the throwaway hetzner server via the T-1 hetzner fixture surface, brings a named cloudflared tunnel connector up on it, runs cloudflared tunnel info <name> through a cloudflared control shim, asserts the reported healthy connector count is at least one, captures a tunnelConnectorUp event on the injected event sink, and tears the connector and server down on exit. If the hetzner fixture path fails locally the driver fails with a pointer naming the failed fixture step (hetzner fixture defects are H-1 scope at HQ, never a warn on this driver). Env-absent branch keeps section 3.5 pass-with-skip.",
18
+ "testable": true
19
+ },
20
+ {
21
+ "id": "AC-15301-2",
22
+ "description": "When CI_HAS_CLOUDFLARE_ACCOUNT is set with the required tunnel credentials, real-account-tunnel-hostname-routes drives an undici fetch against the configured scratch subdomain for the public-hostname sub-case, asserts an HTTP 200 with expected body signature. When CI_HAS_CLOUDFLARE_ACCESS is ALSO set with Access service-token identities, the same probe drives a two-identity JWT sub-case: identity A obtains a JWT via the Service Token exchange and an authenticated fetch to the access-gated hostname succeeds; identity B without a JWT is denied (HTTP 302 to Access or HTTP 401). accountBoundSkipped is never set on the account-set branch; a pass is unreachable from CI_HAS_CLOUDFLARE_ACCOUNT presence alone on this probe. The access-gated sub-case remains pass-with-skip when CI_HAS_CLOUDFLARE_ACCESS is unset (HQ note: not enabled on HQ account today, expected). Env-absent branch keeps pass-with-skip.",
23
+ "testable": true
24
+ },
25
+ {
26
+ "id": "AC-15301-3",
27
+ "description": "manifest-schema-validate.mjs returns aggregateVerdict fail (not warn) with a detail string naming the missing credentials placeholder file and the fixture-setup step that would create it when the credentials placeholder is absent. The warn-on-missing-credentials-placeholder branch is removed.",
28
+ "testable": true
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "createdAt": "2026-09-08T12:17:35.792Z",
3
+ "updatedAt": "2026-09-08T12:19:48.599Z",
4
+ "asA": "rcf-lite maintainer applying the four Cloudflare-platform blueprints",
5
+ "iWant": "no SIMULATE_ read anywhere in a shipped probe body on the four blueprints, no fail envelope committed under .rcf/reports on the four blueprints, and every committed envelope reflecting a shipped-code pass run against the re-anchored probes",
6
+ "soThat": "the mutation-purity rule holds at the probe boundary, envelopes are trusted evidence rather than stale fail records, and honest labelling of local test doubles restores probe-comment credibility",
7
+ "description": "Part of H-2. All SIMULATE_ branches in shipped probe bodies (kv cache-aside-hit-then-miss, kv event-secrecy, DO sole-reader-scan, DO storage-round-trip, DO websocket-hub-broadcast) move to fixture-side shim modules under packages/rcf-lite/test/fixtures/cf-platform/ or .../cf-edge/. New shim files carry the h2-cf-platform-probe-integrity slug prefix (short form h2-cf acceptable). Every driver's negative path is proven by firing the fixture-side mutation switch and observing the FAIL tail; only shipped-code pass envelopes are committed. The committed fail envelope at .rcf/reports/blueprints/platform-cloudflare-kv/event-secrecy.json is deleted and a shipped-code pass envelope committed in its place; the other four kv envelopes are regenerated so committed envelopes reflect re-anchored shipped-code runs. Probe comments labelling doubles as fake state / fake WebSocket pair / fake clock are reworded to name the local double honestly, or adjudicated acceptable with reason in the PR body.",
8
+ "usId": "US-15401",
9
+ "prdId": "PRD-001",
10
+ "reqId": "REQ-154",
11
+ "version": "0.1.0",
12
+ "status": "draft",
13
+ "title": "H-2 cross-cutting probe hygiene: SIMULATE_ branches out of probe bodies into fixture shims; no fail envelope committed; probe comments name test doubles honestly",
14
+ "acceptanceCriteria": [
15
+ {
16
+ "id": "AC-15401-1",
17
+ "description": "A grep for the token SIMULATE_ across every shipped probe module (contributions/probes/*.mjs) on the four Cloudflare-platform blueprints (platform-cloudflare-kv, platform-cloudflare-durable-objects, messaging-queue-cloudflare, edge-cloudflare-tunnel) returns zero hits. Every mutation switch previously read from a probe body is now read from a fixture-side shim module under packages/rcf-lite/test/fixtures/cf-platform/ or .../cf-edge/ whose file name carries the h2-cf-platform-probe-integrity slug prefix (short form h2-cf acceptable). For each moved switch, a corresponding FAIL tail is observed and recorded in the PR provenance transcript when the shims switch is on.",
18
+ "testable": true
19
+ },
20
+ {
21
+ "id": "AC-15401-2",
22
+ "description": "No committed report under .rcf/reports/blueprints/{platform-cloudflare-kv, platform-cloudflare-durable-objects, messaging-queue-cloudflare, edge-cloudflare-tunnel}/ carries aggregateVerdict fail or warn. Every committed envelope on the four blueprints reflects a shipped-code pass run against the re-anchored probes; the previously committed fail envelope at .rcf/reports/blueprints/platform-cloudflare-kv/event-secrecy.json is replaced with a shipped-code pass envelope.",
23
+ "testable": true
24
+ },
25
+ {
26
+ "id": "AC-15401-3",
27
+ "description": "Every probe-comment or probe-utils-comment surfaced by stage-1 marker rows (kv fake clock; DO fake state; DO probe-utils lines 73 and 75; DO websocket-hub-broadcast fake WebSocket pair; kv not-yet elicit-gating wording) is either reworded to name the local test double honestly on its terms (a fixture in-memory driver realises the Workers KV binding shape; a synthetic WebSocket pair; an elicited fake wall-clock advanced deterministically by the fixture; a follow-up work-item note in place of not-yet) or adjudicated acceptable in the PR body with a stated reason. No shipped probe comment on the four blueprints carries a bare fake / not-yet marker without either honest naming or an adjudication row.",
28
+ "testable": true
29
+ }
30
+ ]
31
+ }
@@ -40,8 +40,18 @@
40
40
  # `npm install rcf-lite`.
41
41
 
42
42
  feedVersion: 1
43
- latest: "0.25.0"
43
+ latest: "0.26.0"
44
44
  releases:
45
+ - version: "0.26.0"
46
+ date: "2026-09-09"
47
+ breaking: false
48
+ headlines:
49
+ - "rcf-lite 0.26.0 is a hardening and authoring release. The Cloudflare-platform blueprints on the shelf pick up real-account probe drivers with positive evidence and one shared account-token env matrix."
50
+ - "The blueprint authoring standard gains three new sections (7c REQ-layer sufficiency, 7d positive-evidence verification, 7e single-definition ownership), ten checklist rows and RULE 16 in the managed agent block."
51
+ - "The rcf define blueprint verb group gains a lint-consistency subverb, a per-slug disposition ledger with an apply-time prompt and a read-only dispositions view, and the identity seed grows a register field. The schema floor moves to rcf-schemas 0.6.2."
52
+ minAgentAction: null
53
+ notesUrl: "https://stravica.ai/docs/rcf/changelog/"
54
+
45
55
  - version: "0.25.0"
46
56
  date: "2026-09-08"
47
57
  breaking: false
@@ -21,6 +21,7 @@ import { updateManifest } from './manifest-writer.js';
21
21
  import { stampId } from './namespace.js';
22
22
  import { nextResolutionId } from './resolutions.js';
23
23
  import { buildRefusalMessage, discoverAppliedCapabilities, runCustomAuthCapabilityElicits, runElicitationPhase, writeSidecar } from './capabilities.js';
24
+ import { APPLY_DISPOSITION_PROMPT, initialiseLedger } from './disposition-ledger.js';
24
25
 
25
26
  /**
26
27
  * @typedef {object} ApplyResult
@@ -410,11 +411,36 @@ export async function applyBlueprint({ projectRoot, tree, source, displaySource,
410
411
  });
411
412
  }
412
413
 
414
+ // Disposition ledger. On a fresh apply, initialise
415
+ // rcf/blueprints/<slug>.disposition.json with one record per
416
+ // contributed AC so the applying agent has a machine-readable walk
417
+ // list. Reads AC ids and their `disposition` field (rcf-schemas
418
+ // 0.6.2) from the SOURCE user-story JSON so the record reflects the
419
+ // authored shape, not the stamped one; `fixed` ACs land as accepted
420
+ // with the sentinel reason, `template` and unmarked ACs land as
421
+ // pending-disposition. Re-apply leaves an existing ledger untouched.
422
+ let ledgerPath = null;
423
+ let dispositionPrompt = null;
424
+ let acCount = 0;
425
+ if (!dryRun) {
426
+ const acDescriptors = await collectAcDescriptorsFromSource(source, blueprint, namespace);
427
+ acCount = acDescriptors.length;
428
+ if (acCount > 0) {
429
+ const ledgerRes = await initialiseLedger({ projectRoot, slug: appliedSlug, acDescriptors, now });
430
+ ledgerPath = ledgerRes.path;
431
+ if (!ledgerRes.alreadyExisted) {
432
+ dispositionPrompt = APPLY_DISPOSITION_PROMPT(appliedSlug, blueprint.version, acCount);
433
+ }
434
+ }
435
+ }
436
+
413
437
  return {
414
438
  applied: true,
415
439
  slug: appliedSlug,
416
440
  version: blueprint.version,
417
441
  contributions: writtenContributions,
442
+ ...(ledgerPath ? { ledgerPath, dispositionAcCount: acCount } : {}),
443
+ ...(dispositionPrompt ? { dispositionPrompt } : {}),
418
444
  ...(wroteSidecar ? { sidecarPath, appliedCapabilities, appliedElicitations } : {}),
419
445
  // Companion-suggestion mechanism (spec 2.6). Bubble the source
420
446
  // blueprint's `suggestedCompanions[]` up on the result so the CLI
@@ -428,6 +454,44 @@ export async function applyBlueprint({ projectRoot, tree, source, displaySource,
428
454
  };
429
455
  }
430
456
 
457
+ /**
458
+ * Walk the blueprint's user-story contributions on-disk and collect
459
+ * every AC as a { id, storyId, sourceDisposition? } descriptor for the
460
+ * disposition ledger. AC ids and story ids are stamped with the
461
+ * applied namespace so they match what the applying agent sees in the
462
+ * tree (rather than the raw blueprint-slug ids on the source files).
463
+ *
464
+ * `disposition` on the AC (rcf-schemas 0.6.2 $defs.acDisposition) is
465
+ * `fixed` | `template` and is optional; an unmarked AC lands as
466
+ * pending-disposition with no `sourceDisposition` field so the operator
467
+ * can decide.
468
+ */
469
+ async function collectAcDescriptorsFromSource(sourcePath, blueprint, namespace) {
470
+ const descriptors = [];
471
+ for (const c of blueprint.contributions ?? []) {
472
+ if (c.kind !== 'us' || typeof c.path !== 'string') continue;
473
+ const abs = join(sourcePath, 'contributions', c.path);
474
+ let doc;
475
+ try { doc = JSON.parse(await readFile(abs, 'utf8')); } catch { continue; }
476
+ const rawStoryId = typeof doc.usId === 'string' ? doc.usId : c.id;
477
+ // Re-stamp under the applied namespace so a library-qualified apply
478
+ // (namespace differs from the blueprint's own slug) records the
479
+ // story id the applying agent will actually see on the tree.
480
+ const storyStamped = stampId(rawStoryId, namespace);
481
+ const storyId = 'error' in storyStamped ? rawStoryId : storyStamped.id;
482
+ const acs = Array.isArray(doc.acceptanceCriteria) ? doc.acceptanceCriteria : [];
483
+ for (const ac of acs) {
484
+ if (!ac || typeof ac.id !== 'string') continue;
485
+ const descriptor = { id: ac.id, storyId };
486
+ if (ac.disposition === 'fixed' || ac.disposition === 'template') {
487
+ descriptor.sourceDisposition = ac.disposition;
488
+ }
489
+ descriptors.push(descriptor);
490
+ }
491
+ }
492
+ return descriptors;
493
+ }
494
+
431
495
  function stampContributions(contributions, namespace) {
432
496
  const out = [];
433
497
  for (const c of contributions ?? []) {
@@ -0,0 +1,432 @@
1
+ // Blueprint chain-consistency lint (spec section 5 of the integration
2
+ // and contradiction protocol, 2026-09-09).
3
+ //
4
+ // Two passes over ONE blueprint's own JSON and markdown. Pure functions;
5
+ // the CLI seam loads the blueprint from disk and hands the model in.
6
+ //
7
+ // PASS 1: single-definition ownership, MECHANICAL ONLY.
8
+ // The check is intentionally conservative. Pass 1 fires when the
9
+ // same identifier-shaped literal appears on more than one artefact
10
+ // and one of the appearances is case-INCONSISTENT with the owning
11
+ // TAC on a case-sensitive axis (HTTP header, env-var, camelCase
12
+ // JSON field name in a strict-parse context). A byte-identical
13
+ // restatement is only flagged when the owning TAC declares the
14
+ // token on its `interfaces[]` (the strict interface surface) and
15
+ // the restating AC carries no `ownerRef` back to that TAC. Every
16
+ // other kind of appearance is treated as a reference, not a
17
+ // restatement (the mechanical detector cannot reliably tell a
18
+ // duplicate literal from a legitimate mention without domain
19
+ // knowledge, and the spec explicitly names four human-read shapes
20
+ // the lint does not catch, per section 5.7).
21
+ //
22
+ // PASS 2: REQ delivery, MECHANICAL.
23
+ // A REQ whose description carries a promise phrase (must / requires
24
+ // / guarantees / ceiling / never / invariant / on boot / at boot /
25
+ // event / boundary) must carry a `deliveredBy` link into a TAC or
26
+ // ADR. When the link points at a TAC in the same blueprint, the
27
+ // TAC's responsibilities and interfaces must reference the field
28
+ // (or the REQ's subject, when no `field` sub-path is given).
29
+ //
30
+ // Every finding carries a stable `id` the operator can quote in the
31
+ // blueprint README under "Known chain-consistency-lint suppressions"
32
+ // (pass 1 only; pass 2 findings are not suppressible per spec 5.8).
33
+
34
+ import { readFile, readdir } from 'node:fs/promises';
35
+ import { join } from 'node:path';
36
+
37
+ /**
38
+ * @typedef {object} ConsistencyFinding
39
+ * @property {string} id
40
+ * @property {'pass1'|'pass2'} pass
41
+ * @property {'contractDrift'|'undeclaredRestatement'|'reqNoDelivery'|'reqDeliveryNotCarried'} kind
42
+ * @property {string} subject
43
+ * @property {string[]} refs
44
+ * @property {string} message
45
+ * @property {boolean} [suppressed]
46
+ * @property {string} [suppressionReason]
47
+ */
48
+
49
+ const HTTP_HEADER_RE = /^[A-Z][A-Za-z0-9]*(-[A-Z][A-Za-z0-9]*)+$/;
50
+ const HTTP_HEADER_LOWER_RE = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)+$/;
51
+ const ENV_VAR_RE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
52
+ const CAMEL_JSON_FIELD_RE = /^[a-z][a-zA-Z0-9]*$/;
53
+
54
+ /**
55
+ * Extract identifier-shaped tokens from a body of text: dashed
56
+ * identifiers (Cf-Turnstile-Response, run_worker_first),
57
+ * ALL_CAPS_ENV_VARS, and camelCase / PascalCase runs. Duplicates are
58
+ * preserved so callers can index by occurrence.
59
+ *
60
+ * @param {string} text
61
+ */
62
+ function extractTokens(text) {
63
+ if (typeof text !== 'string' || text.length === 0) return [];
64
+ const out = [];
65
+ const re = /[A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)+|[A-Z][a-z]+(?:[A-Z][a-z]*)+|[a-z]+(?:[A-Z][a-z]*){1,}/g;
66
+ for (const m of text.matchAll(re)) out.push(m[0]);
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Classify a token for case-sensitivity purposes.
72
+ *
73
+ * @param {string} token
74
+ * @returns {'header'|'headerLower'|'envVar'|'jsonField'|'identifier'}
75
+ */
76
+ function tokenClass(token) {
77
+ if (ENV_VAR_RE.test(token)) return 'envVar';
78
+ if (HTTP_HEADER_RE.test(token)) return 'header';
79
+ if (HTTP_HEADER_LOWER_RE.test(token)) return 'headerLower';
80
+ if (CAMEL_JSON_FIELD_RE.test(token)) return 'jsonField';
81
+ return 'identifier';
82
+ }
83
+
84
+ // TAC / ADR / REQ / US / PRD / TAD / BS / FBS id prefixes: these read
85
+ // as identifier-shaped but they are meta references, not contract
86
+ // literals. Skip them in both directions of the drift check.
87
+ const ID_TOKEN_RE = /^(?:TAC|ADR|REQ|US|PRD|TAD|BS|FBS|TS|CN|AC)[-_A-Z0-9-]*$/;
88
+
89
+ // Small stop-list of ordinary English hyphenated / camel-cased words
90
+ // that appear as identifier-shape but are prose, not contracts.
91
+ const PROSE_TOKENS = new Set([
92
+ 'stable-coded', 'per-environment', 'wall-clock', 'operator-authored',
93
+ 'project-authored', 'vendor-resolved', 'required-absent',
94
+ 'deployable-to-slice', 'rotation-days', 'admin-UI',
95
+ 'read-through', 'write-through', 'follow-up',
96
+ ]);
97
+
98
+ function isNoise(token) {
99
+ if (token.length < 4) return true;
100
+ if (ID_TOKEN_RE.test(token)) return true;
101
+ if (PROSE_TOKENS.has(token)) return true;
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * Owned-token dictionary. Keyed by lower-case token; the value is the
107
+ * canonical spelling on the owning TAC's interface surface, plus the
108
+ * TAC id and field where it lives.
109
+ *
110
+ * Only `interfaces[].name` and `interfaces[].summary` (backtick-quoted
111
+ * spans only) plus `internalStructure` (backtick-quoted spans only)
112
+ * count as owning surfaces. `responsibilities[]` and `purpose` are
113
+ * prose narrative where a mention is a reference, not a declaration.
114
+ */
115
+ function collectOwnedTokens(tacs) {
116
+ /** @type {Map<string, { canonical: string, tacId: string, field: string, klass: string }>} */
117
+ const owned = new Map();
118
+ // record() takes an `onlySensitive` flag. For strict interface
119
+ // surfaces (`interfaces[].name`, `internalStructure` backtick spans,
120
+ // `responsibilities[]` backtick spans) we record every non-prose
121
+ // token including camelCase field names. For less-strict prose
122
+ // surfaces (interface summaries, purpose, responsibilities prose)
123
+ // we only record tokens classified as `header` / `headerLower` /
124
+ // `envVar`, because a prose mention of a camelCase word is usually
125
+ // a reference and only case-sensitive axes reliably indicate a
126
+ // literal declaration.
127
+ const record = (token, tacId, field, onlySensitive) => {
128
+ if (isNoise(token)) return;
129
+ const klass = tokenClass(token);
130
+ if (klass === 'identifier') return;
131
+ if (onlySensitive && klass === 'jsonField') return;
132
+ const key = token.toLowerCase();
133
+ if (!owned.has(key)) owned.set(key, { canonical: token, tacId, field, klass });
134
+ };
135
+ for (const tac of tacs) {
136
+ const tacId = tac.tacId || tac.id || '(unknown-tac)';
137
+ // Strict interface surfaces first so their spellings win the
138
+ // Map's first-write-wins semantics over any weaker prose mention.
139
+ if (Array.isArray(tac.interfaces)) {
140
+ for (let i = 0; i < tac.interfaces.length; i += 1) {
141
+ const iface = tac.interfaces[i];
142
+ if (iface && typeof iface.name === 'string') {
143
+ for (const t of extractTokens(iface.name)) record(t, tacId, `interfaces[${i}].name`, false);
144
+ }
145
+ if (iface && typeof iface.summary === 'string') {
146
+ for (const m of iface.summary.matchAll(/`+([^`]+)`+/g)) {
147
+ for (const t of extractTokens(m[1])) record(t, tacId, `interfaces[${i}].summary`, false);
148
+ }
149
+ // Plain-text summary: case-sensitive axes only. This is what
150
+ // catches `Cf-Turnstile-Response` on TAC-3602's guard summary.
151
+ for (const t of extractTokens(iface.summary)) record(t, tacId, `interfaces[${i}].summary`, true);
152
+ }
153
+ }
154
+ }
155
+ if (typeof tac.internalStructure === 'string') {
156
+ for (const m of tac.internalStructure.matchAll(/`+([^`]+)`+/g)) {
157
+ for (const t of extractTokens(m[1])) record(t, tacId, 'internalStructure', false);
158
+ }
159
+ for (const t of extractTokens(tac.internalStructure)) record(t, tacId, 'internalStructure', true);
160
+ }
161
+ if (Array.isArray(tac.responsibilities)) {
162
+ for (let i = 0; i < tac.responsibilities.length; i += 1) {
163
+ const s = tac.responsibilities[i];
164
+ if (typeof s !== 'string') continue;
165
+ for (const m of s.matchAll(/`+([^`]+)`+/g)) {
166
+ for (const t of extractTokens(m[1])) record(t, tacId, `responsibilities[${i}]`, false);
167
+ }
168
+ for (const t of extractTokens(s)) record(t, tacId, `responsibilities[${i}]`, true);
169
+ }
170
+ }
171
+ if (typeof tac.purpose === 'string') {
172
+ // Purpose prose: header / envVar only (never fires on ordinary
173
+ // English or camelCase identifiers).
174
+ for (const t of extractTokens(tac.purpose)) record(t, tacId, 'purpose', true);
175
+ }
176
+ }
177
+ return owned;
178
+ }
179
+
180
+ /**
181
+ * Collect artefact surfaces (holderId, holderKind, field, text,
182
+ * optional ownerRef on ACs). Used by pass 1's cross-surface walk.
183
+ */
184
+ function collectArtefactSurfaces(input) {
185
+ const surfaces = [];
186
+ for (const req of input.reqs) {
187
+ const rid = req.reqId || req.id || '(unknown-req)';
188
+ if (typeof req.description === 'string') surfaces.push({ holderId: rid, holderKind: 'req', field: 'description', text: req.description });
189
+ }
190
+ for (const us of input.userStories) {
191
+ const usid = us.usId || us.id || '(unknown-us)';
192
+ if (typeof us.iWant === 'string') surfaces.push({ holderId: usid, holderKind: 'us', field: 'iWant', text: us.iWant });
193
+ if (Array.isArray(us.acceptanceCriteria)) {
194
+ for (const ac of us.acceptanceCriteria) {
195
+ const acid = ac.id || `${usid}-AC?`;
196
+ const ownerRef = ac.ownerRef && typeof ac.ownerRef === 'object' ? ac.ownerRef : null;
197
+ for (const f of ['description', 'given', 'when', 'then']) {
198
+ if (typeof ac[f] === 'string') surfaces.push({ holderId: acid, holderKind: 'ac', field: f, text: ac[f], parentUsId: usid, ownerRef });
199
+ }
200
+ }
201
+ }
202
+ }
203
+ for (const adr of input.adrs) {
204
+ const aid = adr.adrId || adr.id || '(unknown-adr)';
205
+ for (const f of ['context', 'decision', 'consequences']) {
206
+ if (typeof adr[f] === 'string') surfaces.push({ holderId: aid, holderKind: 'adr', field: f, text: adr[f] });
207
+ }
208
+ }
209
+ if (typeof input.guideMarkdown === 'string' && input.guideMarkdown.length > 0) {
210
+ surfaces.push({ holderId: 'guide', holderKind: 'guide', field: 'body', text: input.guideMarkdown });
211
+ }
212
+ return surfaces;
213
+ }
214
+
215
+ /**
216
+ * @param {{ slug: string, sourcePath: string, tacs: object[], reqs: object[], userStories: object[], adrs: object[], guideMarkdown?: string }} input
217
+ * @returns {ConsistencyFinding[]}
218
+ */
219
+ export function runPass1(input) {
220
+ const owned = collectOwnedTokens(input.tacs);
221
+ if (owned.size === 0) return [];
222
+ const surfaces = collectArtefactSurfaces(input);
223
+ /** @type {ConsistencyFinding[]} */
224
+ const findings = [];
225
+ const seen = new Set();
226
+
227
+ for (const surface of surfaces) {
228
+ for (const raw of extractTokens(surface.text)) {
229
+ if (isNoise(raw)) continue;
230
+ const key = raw.toLowerCase();
231
+ const owner = owned.get(key);
232
+ if (!owner) continue;
233
+ const isSensitive = owner.klass === 'header' || owner.klass === 'envVar' || owner.klass === 'headerLower';
234
+ // Contract drift: the same lower-cased token, spelled
235
+ // differently, on a case-sensitive axis (HTTP header or
236
+ // env-var). This is the primary mechanical catch and includes
237
+ // the `Cf-Turnstile-Response` / `cf-turnstile-response`
238
+ // specimen shape.
239
+ if (isSensitive && owner.canonical !== raw) {
240
+ // Sentence-start capitalisation is not contract drift.
241
+ // Skip when the two tokens differ ONLY in the case of the
242
+ // very first character (rest of the token is byte-identical).
243
+ if (owner.canonical.length === raw.length
244
+ && owner.canonical.slice(1) === raw.slice(1)
245
+ && owner.canonical[0].toLowerCase() === raw[0].toLowerCase()) continue;
246
+ const id = `pass1-drift-${owner.tacId}-${owner.canonical}`;
247
+ const dedupe = `${id}::${surface.holderId}::${raw}`;
248
+ if (!seen.has(dedupe)) {
249
+ seen.add(dedupe);
250
+ findings.push({
251
+ id, pass: 'pass1', kind: 'contractDrift',
252
+ subject: owner.canonical,
253
+ refs: [owner.tacId, surface.holderId],
254
+ message: `${surface.holderId}.${surface.field} spells '${owner.canonical}' as '${raw}'; owner ${owner.tacId}.${owner.field} declares '${owner.canonical}'.`,
255
+ });
256
+ }
257
+ continue;
258
+ }
259
+ // Undeclared restatement: only fires for AC surfaces (the
260
+ // schema field `ownerRef` lives on AC records), only when the
261
+ // owning surface is an `interfaces[].name` (the strict
262
+ // interface surface), and only when the AC does not name an
263
+ // `ownerRef.tacId` matching the owning TAC. Every other cross
264
+ // surface mention is read as a reference, not a restatement.
265
+ if (surface.holderKind !== 'ac') continue;
266
+ if (!owner.field.startsWith('interfaces[') || !owner.field.endsWith('].name')) continue;
267
+ const ownerRef = surface.ownerRef;
268
+ const declared = ownerRef && typeof ownerRef.tacId === 'string' && ownerRef.tacId === owner.tacId;
269
+ if (declared) continue;
270
+ const id = `pass1-restated-${owner.tacId}-${owner.canonical}`;
271
+ const dedupe = `${id}::${surface.holderId}`;
272
+ if (seen.has(dedupe)) continue;
273
+ seen.add(dedupe);
274
+ findings.push({
275
+ id, pass: 'pass1', kind: 'undeclaredRestatement',
276
+ subject: owner.canonical,
277
+ refs: [owner.tacId, surface.holderId],
278
+ message: `${surface.holderId}.${surface.field} restates '${owner.canonical}' owned by ${owner.tacId}.${owner.field}; declare ownerRef or turn the mention into a reference.`,
279
+ });
280
+ }
281
+ }
282
+ return findings;
283
+ }
284
+
285
+ // REQ description phrases that read as an externally observable
286
+ // promise the lint asks a TAC or ADR to deliver.
287
+ const PROMISE_TERMS = [
288
+ 'must', 'requires', 'required', 'guarantee', 'guarantees', 'guaranteed',
289
+ 'ceiling', 'never', 'invariant', 'on boot', 'at boot', 'boot event',
290
+ 'always', 'ensures', 'refuse', 'refuses', 'forbid', 'forbidden',
291
+ ];
292
+
293
+ export function runPass2(input) {
294
+ const findings = [];
295
+ const tacsById = new Map();
296
+ for (const t of input.tacs) tacsById.set(t.tacId || t.id, t);
297
+ for (const req of input.reqs) {
298
+ const rid = req.reqId || req.id || '(unknown-req)';
299
+ const desc = typeof req.description === 'string' ? req.description : '';
300
+ const promises = PROMISE_TERMS.some((term) => new RegExp(`(^|[^A-Za-z])${term}([^A-Za-z]|$)`, 'i').test(desc));
301
+ if (!promises) continue;
302
+ const delivery = req.deliveredBy && typeof req.deliveredBy === 'object' ? req.deliveredBy : null;
303
+ if (!delivery || (!delivery.tacId && !delivery.adrId)) {
304
+ findings.push({
305
+ id: `pass2-no-delivery-${rid}`, pass: 'pass2', kind: 'reqNoDelivery',
306
+ subject: rid, refs: [rid],
307
+ message: `${rid}.description promises an externally observable property but declares no deliveredBy link into a TAC or ADR.`,
308
+ });
309
+ continue;
310
+ }
311
+ if (delivery.tacId) {
312
+ const target = tacsById.get(delivery.tacId);
313
+ if (!target) {
314
+ findings.push({
315
+ id: `pass2-delivery-missing-tac-${rid}-${delivery.tacId}`, pass: 'pass2', kind: 'reqDeliveryNotCarried',
316
+ subject: rid, refs: [rid, delivery.tacId],
317
+ message: `${rid}.deliveredBy names TAC ${delivery.tacId} which is not present in the blueprint.`,
318
+ });
319
+ continue;
320
+ }
321
+ const carried = collectTacSurfaceText(target).toLowerCase();
322
+ const field = typeof delivery.field === 'string' ? delivery.field.toLowerCase() : '';
323
+ const fieldTail = field ? field.split('.').pop() : '';
324
+ if (fieldTail && !carried.includes(fieldTail)) {
325
+ findings.push({
326
+ id: `pass2-delivery-field-missing-${rid}-${delivery.tacId}`, pass: 'pass2', kind: 'reqDeliveryNotCarried',
327
+ subject: rid, refs: [rid, delivery.tacId, fieldTail],
328
+ message: `${rid}.deliveredBy points at ${delivery.tacId}.${delivery.field} but that field is not present on the TAC's responsibilities or interfaces.`,
329
+ });
330
+ }
331
+ }
332
+ }
333
+ return findings;
334
+ }
335
+
336
+ function collectTacSurfaceText(tac) {
337
+ const parts = [];
338
+ if (typeof tac.purpose === 'string') parts.push(tac.purpose);
339
+ if (typeof tac.internalStructure === 'string') parts.push(tac.internalStructure);
340
+ if (Array.isArray(tac.responsibilities)) parts.push(tac.responsibilities.filter((s) => typeof s === 'string').join(' '));
341
+ if (Array.isArray(tac.interfaces)) {
342
+ for (const iface of tac.interfaces) {
343
+ if (iface && typeof iface.name === 'string') parts.push(iface.name);
344
+ if (iface && typeof iface.summary === 'string') parts.push(iface.summary);
345
+ }
346
+ }
347
+ return parts.join(' ');
348
+ }
349
+
350
+ /**
351
+ * Load a blueprint from disk into the shape the passes consume.
352
+ *
353
+ * @param {string} sourcePath
354
+ * @returns {Promise<{ slug: string, sourcePath: string, tacs: object[], reqs: object[], userStories: object[], adrs: object[], guideMarkdown: string } | { error: string }>}
355
+ */
356
+ export async function loadForLint(sourcePath) {
357
+ let meta;
358
+ try {
359
+ const raw = await readFile(join(sourcePath, 'blueprint.json'), 'utf8');
360
+ meta = JSON.parse(raw);
361
+ } catch (err) {
362
+ return { error: `cannot read blueprint.json under ${sourcePath}: ${err.message}` };
363
+ }
364
+ const slug = meta.slug || '(unknown-slug)';
365
+ const contributions = Array.isArray(meta.contributions) ? meta.contributions : [];
366
+ const tacs = []; const reqs = []; const userStories = []; const adrs = [];
367
+ for (const c of contributions) {
368
+ if (!c || typeof c.path !== 'string') continue;
369
+ const abs = join(sourcePath, 'contributions', c.path);
370
+ let doc;
371
+ try { doc = JSON.parse(await readFile(abs, 'utf8')); } catch { continue; }
372
+ switch (c.kind) {
373
+ case 'tac': tacs.push(doc); break;
374
+ case 'req': reqs.push(doc); break;
375
+ case 'us': userStories.push(doc); break;
376
+ case 'adr': adrs.push(doc); break;
377
+ default: break;
378
+ }
379
+ }
380
+ let guideMarkdown = '';
381
+ try {
382
+ const entries = await readdir(join(sourcePath, 'guide'));
383
+ for (const name of entries) {
384
+ if (!name.endsWith('.md')) continue;
385
+ guideMarkdown += `${await readFile(join(sourcePath, 'guide', name), 'utf8')}\n`;
386
+ }
387
+ } catch { /* no guide directory is fine */ }
388
+ return { slug, sourcePath, tacs, reqs, userStories, adrs, guideMarkdown };
389
+ }
390
+
391
+ /**
392
+ * Extract "Known chain-consistency-lint suppressions" entries from a
393
+ * blueprint's README.md. Returns a Map from finding id to reason.
394
+ */
395
+ export async function loadSuppressions(sourcePath) {
396
+ const out = new Map();
397
+ let readme;
398
+ try { readme = await readFile(join(sourcePath, 'README.md'), 'utf8'); } catch { return out; }
399
+ const m = readme.match(/##\s+Known chain-consistency-lint suppressions([\s\S]*?)(\n##\s+|$)/);
400
+ if (!m) return out;
401
+ for (const line of m[1].split('\n')) {
402
+ const rm = line.match(/^\s*[-*]\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s*:\s*(.+?)\s*$/);
403
+ if (rm) out.set(rm[1], rm[2]);
404
+ }
405
+ return out;
406
+ }
407
+
408
+ /**
409
+ * Run both passes and shape the result envelope. Pass 1 findings that
410
+ * appear in `suppressions` are marked `suppressed: true`; pass 2
411
+ * findings are never suppressible.
412
+ */
413
+ export function runLint(input, suppressions = new Map()) {
414
+ const pass1 = runPass1(input);
415
+ const pass2 = runPass2(input);
416
+ let suppressedCount = 0;
417
+ for (const f of pass1) {
418
+ if (suppressions.has(f.id)) {
419
+ f.suppressed = true;
420
+ f.suppressionReason = suppressions.get(f.id);
421
+ suppressedCount += 1;
422
+ }
423
+ }
424
+ const findings = [...pass1, ...pass2];
425
+ const unsuppressed = findings.filter((f) => !f.suppressed);
426
+ return {
427
+ blueprint: input.slug,
428
+ findings,
429
+ verdict: unsuppressed.length === 0 ? 'pass' : 'fail',
430
+ passCounts: { pass1: pass1.length, pass2: pass2.length, suppressed: suppressedCount },
431
+ };
432
+ }