rcf-lite 0.30.0 → 0.32.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 (65) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/blueprints/deploy-cloudflare-workers/contributions/user-stories/deploy-cloudflare-workers-us-12113.json +1 -1
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +15 -10
  5. package/package.json +1 -1
  6. package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
  7. package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
  8. package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
  9. package/rcf/code-nodes/cn-017.json +4 -2
  10. package/rcf/code-nodes/cn-771.json +15 -0
  11. package/rcf/code-nodes/cn-772.json +14 -0
  12. package/rcf/code-nodes/cn-773.json +16 -0
  13. package/rcf/code-nodes/cn-774.json +20 -0
  14. package/rcf/code-nodes/cn-775.json +15 -0
  15. package/rcf/code-nodes/cn-776.json +17 -0
  16. package/rcf/code-nodes/cn-777.json +21 -0
  17. package/rcf/code-nodes/cn-778.json +14 -0
  18. package/rcf/fbs/fbs-004.json +4 -1
  19. package/rcf/fbs/fbs-201.json +2 -1
  20. package/rcf/fbs/fbs-202.json +18 -2
  21. package/rcf/fbs/fbs-203.json +12 -4
  22. package/rcf/requirements/req-002.json +2 -2
  23. package/rcf/requirements/req-173.json +2 -2
  24. package/rcf/requirements/req-174.json +2 -2
  25. package/rcf/tacs/tac-4122-define-gates.json +7 -2
  26. package/rcf/tacs/tac-4127-readiness-view.json +3 -2
  27. package/rcf/test-suites/ts-004.json +82 -2
  28. package/rcf/test-suites/ts-007.json +1 -1
  29. package/rcf/test-suites/ts-215.json +64 -1
  30. package/rcf/test-suites/ts-216.json +18 -2
  31. package/rcf/test-suites/ts-219.json +10 -2
  32. package/rcf/test-suites/ts-220.json +18 -2
  33. package/rcf/test-suites/ts-223.json +24 -2
  34. package/rcf/test-suites/ts-225.json +10 -2
  35. package/rcf/test-suites/ts-229.json +49 -3
  36. package/rcf/test-suites/ts-230.json +26 -2
  37. package/rcf/test-suites/ts-231.json +16 -1
  38. package/rcf/test-suites/ts-236.json +44 -0
  39. package/rcf/user-stories/us-17302.json +36 -1
  40. package/rcf/user-stories/us-17401.json +11 -1
  41. package/rcf/user-stories/us-17402.json +6 -1
  42. package/rcf/user-stories/us-17403.json +11 -1
  43. package/rcf/user-stories/us-17404.json +16 -1
  44. package/rcf/user-stories/us-17405.json +7 -2
  45. package/rcf/user-stories/us-18001.json +6 -1
  46. package/rcf/user-stories/us-202.json +25 -1
  47. package/rcf/user-stories/us-205.json +6 -1
  48. package/rcf/user-stories/us-206.json +25 -4
  49. package/rcf/user-stories/us-207.json +36 -0
  50. package/releases/releases.yaml +20 -1
  51. package/src/cli/create.js +22 -2
  52. package/src/cli/freeze.js +8 -0
  53. package/src/cli/ledger.js +96 -4
  54. package/src/cli/readiness.js +17 -39
  55. package/src/core/errors/index.js +7 -1
  56. package/src/core/store/writer.js +181 -13
  57. package/src/define/interface-paths.js +47 -0
  58. package/src/define/ledgers.js +86 -7
  59. package/src/mcp/tools.js +1 -1
  60. package/src/query/gates.js +431 -163
  61. package/src/view/html-page.js +43 -13
  62. package/src/view/live-client.js +18 -10
  63. package/src/view/page-init.js +149 -8
  64. package/src/view/style.css +107 -20
  65. package/src/view/view-index.js +22 -0
@@ -173,7 +173,7 @@ export const CHECK_QUESTION = /** @type {const} */ ({
173
173
  'skeleton:reqShape': 'Every requirement in scope carries a shape classification.',
174
174
  'skeleton:tadPersistence': 'TAD data architecture lists dataStores and coreEntities for persistence REQs.',
175
175
  'skeleton:deployAdr': 'Exactly one Deploy target or Deploy deferral ADR exists.',
176
- 'skeleton:standardsCited': 'Every registered standards pack is cited in a REQ rationale or an ADR, or waived by a concern-ledger entry.',
176
+ 'skeleton:standardsCited': 'Every registered standards pack is cited, applied or waived.',
177
177
  'shapes:tacHasInterface': 'Every TAC in scope has at least one interface.',
178
178
  'shapes:kindVocabulary': 'Every interface kind is in the closed vocabulary.',
179
179
  'shapes:draftSettled': 'Every draft interface has been settled (the [draft] marker is removed).',
@@ -378,16 +378,39 @@ export function parseInterfaceTemplate(iface) {
378
378
  return { kind, missing };
379
379
  }
380
380
 
381
+ /**
382
+ * Known bare repo filenames the slash-or-extension rule would otherwise
383
+ * silently skip (ruling R6 2026-10-05). A `path: Dockerfile` on an
384
+ * interface description names a real file at the repo root, so the
385
+ * `shapes:pathsResolve` check should see it. Exported so the tests can
386
+ * assert the list verbatim; a bare word outside this set still fails
387
+ * the slash-or-extension rule as before.
388
+ */
389
+ export const BARE_PATH_WHITELIST = /** @type {const} */ ([
390
+ 'Dockerfile',
391
+ 'Containerfile',
392
+ 'Makefile',
393
+ 'LICENSE',
394
+ 'Procfile',
395
+ 'Justfile',
396
+ 'Rakefile',
397
+ 'Gemfile',
398
+ 'CODEOWNERS',
399
+ ]);
400
+ const BARE_PATH_WHITELIST_SET = new Set(BARE_PATH_WHITELIST);
401
+
381
402
  /**
382
403
  * Rough `path:` token scanner for an interface description. Returns
383
404
  * every candidate path token the description names so the CLI can
384
405
  * resolve them on disk and the gate can decide `shapes:pathsResolve`
385
406
  * against the resolved map (ADR-4131). A candidate is any text that
386
407
  * follows a `path:` or `path =` marker and looks like a repo-relative
387
- * path (contains a `/` or ends with a file extension, no scheme). The
388
- * scanner is deliberately conservative: an http URL, a bare word, a
389
- * glob or a template placeholder is not a candidate. Exported for the
390
- * CLI's resolve-on-disk pass.
408
+ * path (contains a `/` or ends with a file extension, or is one of the
409
+ * known bare repo names in BARE_PATH_WHITELIST per ruling R6
410
+ * 2026-10-05, no scheme). The scanner is deliberately conservative: an
411
+ * http URL, a bare word outside the whitelist, a glob or a template
412
+ * placeholder is not a candidate. Exported for the CLI's resolve-on-disk
413
+ * pass.
391
414
  *
392
415
  * @param {string} description
393
416
  * @returns {string[]}
@@ -409,6 +432,11 @@ export function extractInterfacePathTokens(description) {
409
432
  // `src/x.js` or `packages/rcf-lite/CHANGELOG.md`; those don't
410
433
  // start with `/`.
411
434
  if (token.startsWith('/') && !/\.[a-zA-Z0-9]+$/.test(token)) continue;
435
+ // Ruling R6 2026-10-05: whitelist the usual bare repo names.
436
+ if (BARE_PATH_WHITELIST_SET.has(token)) {
437
+ out.push(token);
438
+ continue;
439
+ }
412
440
  if (!/\//.test(token) && !/\.[a-zA-Z0-9]+$/.test(token)) continue;
413
441
  out.push(token);
414
442
  }
@@ -434,11 +462,13 @@ export function stagePolicy(stage) {
434
462
 
435
463
  /**
436
464
  * ADR-4131 (0.30.0 PR 5): the three gates that accept a recorded
437
- * `--ack` override. `stagePolicy` keeps returning `blocking` for every
438
- * stage so the readyToBuild fold stays untouched; `ackable(stage)` is
439
- * the companion signal the readiness and freeze CLIs read to decide
440
- * whether a failing stage honours an acknowledgement at the current
441
- * hash. True for D3, D5 and D6; false for everything else.
465
+ * `--ack` override. `stagePolicy(stage)` returns `warnWithAck` for
466
+ * D3/D5/D6 and `blocking` for everything else (ruling R4 2026-10-05
467
+ * retired the pre-R4 wording that said `stagePolicy` kept returning
468
+ * `blocking` for every stage); `ackable(stage)` is the companion
469
+ * signal the readiness and freeze CLIs read to decide whether a
470
+ * failing stage honours an acknowledgement at the current hash. True
471
+ * for D3, D5 and D6; false for everything else.
442
472
  *
443
473
  * @param {string} stage
444
474
  * @returns {boolean}
@@ -753,6 +783,43 @@ function asSet(scope) {
753
783
  return scope instanceof Set ? scope : new Set(scope ?? []);
754
784
  }
755
785
 
786
+ /**
787
+ * ADR-4138 (DEFINE step 3 rulings R2, R5, R7 2026-10-05 and ruling R13
788
+ * 2026-10-05 PR 292 follow-up): run every tree-wide check for a stage
789
+ * and either return a stage envelope carrying only the tree-wide
790
+ * findings (plus the scope placeholder) folded through `foldState`, or
791
+ * null when every tree-wide check passed, letting the stage proceed to
792
+ * its own scope early-return or full checks list.
793
+ *
794
+ * The helper governs every tree-wide check: `shapes:draftSettled`
795
+ * (R2), `shapes:entityJoin` (R5), `skeleton:standardsCited` (R7),
796
+ * `skeleton:deployAdr` (R13), `crosscut:securityArchitecture` (R13)
797
+ * and `crosscut:operationalConcerns` (R13). Delta-only checks
798
+ * (templateMarkers, pathsResolve, closedSets, ownerRefResolves) are
799
+ * untouched: they stay on the stage's own full-checks path. The
800
+ * ack-at-current-hash fold is delegated to `foldState` so one
801
+ * implementation owns the acknowledgement rule and the failing
802
+ * envelope carries no stale `notApplicable` reason text (the pre-R13
803
+ * copy of the stage's notApplicable message misled the readiness CLI
804
+ * print line, which reads `stage.reason` on every state).
805
+ *
806
+ * @param {string} stage
807
+ * @param {string} gate
808
+ * @param {Array<{name: string, check: object}>} treeWideChecks
809
+ * @param {object} freezeCtx
810
+ * @returns {{ stage: string, gate: string, state: 'failing' | 'acknowledged', checks: object[] } | null}
811
+ */
812
+ function treeWideFailureEnvelope(stage, gate, treeWideChecks, freezeCtx) {
813
+ const anyFailing = treeWideChecks.some(({ check }) => !check.ok);
814
+ if (!anyFailing) return null;
815
+ const checks = [makeCheck(`stage:${stage}:scope`, 'delta', 0, [])];
816
+ for (const { check } of treeWideChecks) checks.push(check);
817
+ // Delegate to foldState: it owns the warn-with-ack fold rule
818
+ // (ADR-4122). The returned envelope carries { state: 'failing' } or
819
+ // { state: 'acknowledged' } and no reason field.
820
+ return foldState(stage, gate, checks, freezeCtx);
821
+ }
822
+
756
823
  // ---------------------------------------------------------------------------
757
824
  // D1 -- Brief intake and ledger (blocking in 0.29.0).
758
825
  // ---------------------------------------------------------------------------
@@ -862,6 +929,81 @@ export function checkD1Brief(ctx) {
862
929
  /** Kinds whose resolution D2 checks: capability / constraint / entity / actor / externalSystem / surface. */
863
930
  const D2_RESOLVING_KINDS = new Set(['capability', 'constraint', 'entity', 'actor', 'externalSystem', 'surface']);
864
931
 
932
+ /**
933
+ * Compute the `skeleton:deployAdr` check (engineer, tree-wide). Pure.
934
+ * Ruling R13 2026-10-05 (ADR-4138 extended, PR 292 follow-up): exactly
935
+ * one Deploy target or Deploy deferral ADR exists tree-wide (detected
936
+ * by title prefix per proposal section 11). The check runs ahead of
937
+ * the D2 scope early-return so a narrowed scope that excludes every
938
+ * REQ / PRD / TAD / brief statement still reports a missing (or
939
+ * doubled) Deploy ADR.
940
+ *
941
+ * @param {StageContext} ctx
942
+ * @returns {ReturnType<typeof makeCheck>}
943
+ */
944
+ function computeDeployAdrCheck(ctx) {
945
+ const tree = ctx.tree;
946
+ const deployAdrs = (tree.adrs ?? []).filter((a) => {
947
+ const t = typeof a?.title === 'string' ? a.title : '';
948
+ return t.startsWith('Deploy target:') || t.startsWith('Deploy deferral:');
949
+ });
950
+ const deployFail = [];
951
+ if (deployAdrs.length === 0) deployFail.push({ id: 'ADR:deploy', why: 'no Deploy target or Deploy deferral ADR found (title-prefix scan)' });
952
+ else if (deployAdrs.length > 1) deployFail.push({ id: 'ADR:deploy', why: `${deployAdrs.length} Deploy ADRs found; expected exactly one` });
953
+ return makeCheck('skeleton:deployAdr', 'tree', 1, deployFail);
954
+ }
955
+
956
+ /**
957
+ * Compute the `skeleton:standardsCited` check (engineer, tree-wide).
958
+ * Pure. Every standards pack registered through `rcf define standards`
959
+ * (manifest.standards[].slug) is cited in a REQ `rationale` or an ADR,
960
+ * or satisfied by a concern-ledger entry keyed `standards:<pack>` whose
961
+ * disposition is `applied` or `waived`. Ruling R8 2026-10-05: both
962
+ * `applied` and `waived` satisfy the check (an `applied` entry means the
963
+ * pack was adopted, which is at least as strong as a citation). Ruling
964
+ * R7 2026-10-05: the check runs tree-wide regardless of the D2 scope
965
+ * early-return, through the shared ADR-4138 helper.
966
+ *
967
+ * @param {StageContext} ctx
968
+ * @returns {ReturnType<typeof makeCheck>}
969
+ */
970
+ function computeStandardsCitedCheck(ctx) {
971
+ const tree = ctx.tree;
972
+ const standardsList = Array.isArray(tree.manifest?.standards)
973
+ ? /** @type {any[]} */ (tree.manifest.standards)
974
+ : [];
975
+ /** @type {Array<{ id: string, why: string }>} */
976
+ const standardsFail = [];
977
+ if (standardsList.length > 0) {
978
+ const reqRationales = (tree.requirements ?? [])
979
+ .map((r) => (typeof r?.rationale === 'string' ? r.rationale : ''))
980
+ .join('\n');
981
+ const adrDocs = (tree.adrs ?? []).map((a) => {
982
+ try { return JSON.stringify(a); } catch { return ''; }
983
+ }).join('\n');
984
+ const concernsBody = /** @type {any} */ (ctx.ledgers?.concerns);
985
+ const concernList = Array.isArray(concernsBody?.concerns) ? concernsBody.concerns : [];
986
+ const satisfierSlugs = new Set();
987
+ for (const entry of concernList) {
988
+ if (!entry || typeof entry.concern !== 'string') continue;
989
+ const disposition = typeof entry.disposition === 'string' ? entry.disposition : null;
990
+ // Ruling R8 2026-10-05: both applied and waived satisfy standardsCited.
991
+ if (disposition !== 'applied' && disposition !== 'waived') continue;
992
+ const m = entry.concern.match(/^standards:(.+)$/);
993
+ if (m) satisfierSlugs.add(m[1]);
994
+ }
995
+ for (const pack of standardsList) {
996
+ const slug = typeof pack?.slug === 'string' ? pack.slug : null;
997
+ if (!slug) continue;
998
+ if (satisfierSlugs.has(slug)) continue;
999
+ if (reqRationales.includes(slug)) continue;
1000
+ if (adrDocs.includes(slug)) continue;
1001
+ standardsFail.push({ id: `standards:${slug}`, why: 'uncited, unapplied and unwaived' });
1002
+ }
1003
+ }
1004
+ return makeCheck('skeleton:standardsCited', 'tree', Math.max(standardsList.length, 1), standardsFail);
1005
+ }
1006
+
865
1007
  /**
866
1008
  * D2 -- Requirements and architecture skeleton.
867
1009
  *
@@ -880,7 +1022,25 @@ export function checkD2Skeleton(ctx) {
880
1022
  const prdInScope = tree.prd && scope.has(tree.prd.prdId ?? '');
881
1023
  const tadInScope = tree.tad && scope.has(tree.tad.tadId ?? '');
882
1024
 
1025
+ // Ruling R7 2026-10-05 (ADR-4138) and R13 2026-10-05 (ADR-4138
1026
+ // extended, PR 292 follow-up): skeleton:standardsCited and
1027
+ // skeleton:deployAdr are tree-wide checks; evaluate them before any
1028
+ // scope early-return so a narrowed D2 scope that excludes every
1029
+ // REQ/PRD/TAD/brief statement still reports an uncited pack or a
1030
+ // missing Deploy ADR.
1031
+ const standardsCheck = computeStandardsCitedCheck(ctx);
1032
+ const deployAdrCheck = computeDeployAdrCheck(ctx);
1033
+
883
1034
  if (resolvingStmts.length === 0 && reqInScope.length === 0 && !prdInScope && !tadInScope) {
1035
+ const treeWideFailure = treeWideFailureEnvelope(
1036
+ 'D2', gate,
1037
+ [
1038
+ { name: 'skeleton:standardsCited', check: standardsCheck },
1039
+ { name: 'skeleton:deployAdr', check: deployAdrCheck },
1040
+ ],
1041
+ { currentTreeHash: ctx.currentTreeHash ?? null, freezeGates: /** @type {any} */ (ctx.freeze?.gates) },
1042
+ );
1043
+ if (treeWideFailure) return treeWideFailure;
884
1044
  return notApplicable('D2', gate, 'no resolving brief statements and no REQ / PRD / TAD in scope');
885
1045
  }
886
1046
 
@@ -952,59 +1112,17 @@ export function checkD2Skeleton(ctx) {
952
1112
  checks.push(makeCheck('skeleton:tadPersistence', 'tree', persistenceReq ? 2 : 0, tadFail));
953
1113
 
954
1114
  // Check 4: exactly one deploy or deferral ADR tree-wide. Cheap
955
- // detection by title prefix (proposal §11 assumption).
956
- const deployAdrs = (tree.adrs ?? []).filter((a) => {
957
- const t = typeof a?.title === 'string' ? a.title : '';
958
- return t.startsWith('Deploy target:') || t.startsWith('Deploy deferral:');
959
- });
960
- const deployFail = [];
961
- if (deployAdrs.length === 0) deployFail.push({ id: 'ADR:deploy', why: 'no Deploy target or Deploy deferral ADR found (title-prefix scan)' });
962
- else if (deployAdrs.length > 1) deployFail.push({ id: 'ADR:deploy', why: `${deployAdrs.length} Deploy ADRs found; expected exactly one` });
963
- checks.push(makeCheck('skeleton:deployAdr', 'tree', 1, deployFail));
964
-
965
- // Check 5 (0.30.0 PR 6): skeleton:standardsCited (engineer, over
966
- // tree). Every standards pack registered through `rcf define
967
- // standards` (manifest.standards[].slug) is cited in a REQ
968
- // `rationale` or an ADR, or waived by a concern-ledger entry keyed
969
- // `standards:<pack>`. Fail ids `standards:<pack>` with 'uncited and
970
- // unwaived'. Citation is a case-sensitive substring match of the
971
- // slug against each REQ.rationale and each ADR document (the ADR
972
- // may name a pack in its title, context, decision or consequences;
973
- // the serialised document is scanned once per ADR to cover every
974
- // field). A waiver entry on the concern ledger reads `concern:
975
- // 'standards:<pack>'` with disposition 'applied' or 'waived'.
976
- const standardsList = Array.isArray(tree.manifest?.standards)
977
- ? /** @type {any[]} */ (tree.manifest.standards)
978
- : [];
979
- /** @type {Array<{ id: string, why: string }>} */
980
- const standardsFail = [];
981
- if (standardsList.length > 0) {
982
- const reqRationales = (tree.requirements ?? [])
983
- .map((r) => (typeof r?.rationale === 'string' ? r.rationale : ''))
984
- .join('\n');
985
- const adrDocs = (tree.adrs ?? []).map((a) => {
986
- try { return JSON.stringify(a); } catch { return ''; }
987
- }).join('\n');
988
- const concernsBody = /** @type {any} */ (ctx.ledgers?.concerns);
989
- const concernList = Array.isArray(concernsBody?.concerns) ? concernsBody.concerns : [];
990
- const waiverSlugs = new Set();
991
- for (const entry of concernList) {
992
- if (!entry || typeof entry.concern !== 'string') continue;
993
- const disposition = typeof entry.disposition === 'string' ? entry.disposition : null;
994
- if (disposition !== 'applied' && disposition !== 'waived') continue;
995
- const m = entry.concern.match(/^standards:(.+)$/);
996
- if (m) waiverSlugs.add(m[1]);
997
- }
998
- for (const pack of standardsList) {
999
- const slug = typeof pack?.slug === 'string' ? pack.slug : null;
1000
- if (!slug) continue;
1001
- if (waiverSlugs.has(slug)) continue;
1002
- if (reqRationales.includes(slug)) continue;
1003
- if (adrDocs.includes(slug)) continue;
1004
- standardsFail.push({ id: `standards:${slug}`, why: 'uncited and unwaived' });
1005
- }
1006
- }
1007
- checks.push(makeCheck('skeleton:standardsCited', 'tree', Math.max(standardsList.length, 1), standardsFail));
1115
+ // detection by title prefix (proposal §11 assumption). Ruling R13
1116
+ // 2026-10-05: tree-wide by definition; computed by
1117
+ // computeDeployAdrCheck so the same check is reused ahead of the
1118
+ // scope early-return (ADR-4138 extended).
1119
+ checks.push(deployAdrCheck);
1120
+
1121
+ // Check 5 (0.30.0 PR 6, ruling R7+R8 2026-10-05): skeleton:standardsCited
1122
+ // (engineer, over tree). Computed by `computeStandardsCitedCheck` so the
1123
+ // same check runs ahead of the scope early-return (R7) and so the R8
1124
+ // behaviour (applied | waived | cited all satisfy) lives in one place.
1125
+ checks.push(standardsCheck);
1008
1126
 
1009
1127
  return foldState('D2', gate, checks, {
1010
1128
  currentTreeHash: ctx.currentTreeHash ?? null,
@@ -1019,6 +1137,98 @@ export function checkD2Skeleton(ctx) {
1019
1137
  /** Set form of INTERFACE_KINDS for O(1) checks. */
1020
1138
  const INTERFACE_KINDS_SET = new Set(INTERFACE_KINDS);
1021
1139
 
1140
+ /**
1141
+ * Compute the `shapes:draftSettled` check (engineer, tree-wide). Pure.
1142
+ * Ruling R2 2026-10-05 (ADR-4138): a `[draft]` marker on any TAC
1143
+ * purpose, any interface description or any coreEntities[].description
1144
+ * fails the check regardless of the D3 scope's own early-return. The
1145
+ * three id forms are `<tacId>`, `<tacId>:<name>` and `TAD.entity:<name>`.
1146
+ *
1147
+ * @param {StageContext} ctx
1148
+ * @returns {ReturnType<typeof makeCheck>}
1149
+ */
1150
+ function computeDraftSettledCheck(ctx) {
1151
+ const tree = ctx.tree;
1152
+ const tacs = tree.tacs ?? [];
1153
+ const coreEntities = Array.isArray(tree.tad?.dataArchitecture?.coreEntities)
1154
+ ? /** @type {any[]} */ (tree.tad.dataArchitecture.coreEntities)
1155
+ : [];
1156
+ /** @type {Array<{ id: string, why: string }>} */
1157
+ const draftFindings = [];
1158
+ let totalInterfaces = 0;
1159
+ for (const tac of tacs) {
1160
+ if (parseTacPurposeDraft(tac)) {
1161
+ draftFindings.push({ id: tac.tacId, why: 'draft TAC purpose pre-populated at L1, not yet settled' });
1162
+ }
1163
+ for (const iface of tac.interfaces ?? []) {
1164
+ totalInterfaces += 1;
1165
+ if (parseInterfaceDraft(iface)) {
1166
+ draftFindings.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: 'draft interface pre-populated at L1, not yet settled' });
1167
+ }
1168
+ }
1169
+ }
1170
+ for (const entity of coreEntities) {
1171
+ if (parseCoreEntityDraft(entity)) {
1172
+ draftFindings.push({ id: `TAD.entity:${entity?.name ?? '(unnamed)'}`, why: 'draft core entity pre-populated at L1, not yet settled' });
1173
+ }
1174
+ }
1175
+ const totalDraftHomes = tacs.length + totalInterfaces + coreEntities.length;
1176
+ return makeCheck('shapes:draftSettled', 'tree', Math.max(totalDraftHomes, 1), draftFindings);
1177
+ }
1178
+
1179
+ /**
1180
+ * Compute the `shapes:entityJoin` check (engineer, tree-wide). Pure.
1181
+ * Ruling R5 2026-10-05 (ADR-4138): every entry in
1182
+ * `TAD.dataArchitecture.coreEntities` must be named by exactly one
1183
+ * `recordShape` across the whole tree (matching on the recordShape's
1184
+ * `name` field or an `entity:` line in its description, with a
1185
+ * `[draft]` prefix stripped before the scan). The check runs ahead of
1186
+ * the D3 scope early-return so a narrowed scope that excludes every
1187
+ * recordShape still reports an unjoined entity.
1188
+ *
1189
+ * @param {StageContext} ctx
1190
+ * @returns {ReturnType<typeof makeCheck>}
1191
+ */
1192
+ function computeEntityJoinCheck(ctx) {
1193
+ const tree = ctx.tree;
1194
+ const coreEntities = Array.isArray(tree.tad?.dataArchitecture?.coreEntities)
1195
+ ? /** @type {any[]} */ (tree.tad.dataArchitecture.coreEntities)
1196
+ : [];
1197
+ /** @type {Array<{ id: string, why: string }>} */
1198
+ const joinFindings = [];
1199
+ const recordShapesByEntity = new Map();
1200
+ for (const tac of tree.tacs ?? []) {
1201
+ for (const iface of tac.interfaces ?? []) {
1202
+ if (iface?.kind !== 'recordShape') continue;
1203
+ const ownerLabel = `${tac.tacId}:${iface?.name ?? '(unnamed)'}`;
1204
+ const names = new Set();
1205
+ if (typeof iface?.name === 'string') names.add(iface.name);
1206
+ const desc = typeof iface?.description === 'string' ? stripDraftPrefix(iface.description) : '';
1207
+ const entityRe = /(^|\n)\s*(?:[-*]\s*)?entity\s*[:=]\s*["'`]?([A-Za-z_][A-Za-z0-9_]*)/g;
1208
+ let em;
1209
+ while ((em = entityRe.exec(desc)) !== null) {
1210
+ if (em[2]) names.add(em[2]);
1211
+ }
1212
+ for (const name of names) {
1213
+ const owners = recordShapesByEntity.get(name) ?? [];
1214
+ owners.push(ownerLabel);
1215
+ recordShapesByEntity.set(name, owners);
1216
+ }
1217
+ }
1218
+ }
1219
+ for (const entity of coreEntities) {
1220
+ const name = typeof entity?.name === 'string' ? entity.name : null;
1221
+ if (!name) continue;
1222
+ const owners = recordShapesByEntity.get(name) ?? [];
1223
+ if (owners.length === 0) {
1224
+ joinFindings.push({ id: `TAD.entity:${name}`, why: 'no record shape' });
1225
+ } else if (owners.length > 1) {
1226
+ joinFindings.push({ id: `TAD.entity:${name}`, why: `${owners.length} record shapes` });
1227
+ }
1228
+ }
1229
+ return makeCheck('shapes:entityJoin', 'tree', coreEntities.length, joinFindings);
1230
+ }
1231
+
1022
1232
  /**
1023
1233
  * D3 -- Interface contracts and shapes. 0.29.0 runs the cheap
1024
1234
  * presence, closed-vocabulary and draft-marker checks
@@ -1055,10 +1265,30 @@ export function checkD3Shapes(ctx) {
1055
1265
  const coreEntities = Array.isArray(tree.tad?.dataArchitecture?.coreEntities)
1056
1266
  ? /** @type {any[]} */ (tree.tad.dataArchitecture.coreEntities)
1057
1267
  : [];
1058
- const draftEntities = coreEntities.filter(parseCoreEntityDraft);
1059
1268
 
1060
- if (tacsInScope.length === 0 && shapedReqInScope.length === 0 && draftEntities.length === 0) {
1061
- return notApplicable('D3', gate, 'no TAC, no shaped REQ (httpApi/persistence/auth) and no [draft] core entity in scope');
1269
+ // Ruling R2+R5 2026-10-05 (ADR-4138): shapes:draftSettled and
1270
+ // shapes:entityJoin are tree-wide checks; evaluate them before any
1271
+ // scope early-return so a narrowed D3 scope that excludes every TAC
1272
+ // and shaped REQ still reports a [draft] anywhere in the tree and an
1273
+ // unjoined core entity.
1274
+ const draftCheck = computeDraftSettledCheck(ctx);
1275
+ const entityJoinCheck = computeEntityJoinCheck(ctx);
1276
+
1277
+ if (tacsInScope.length === 0 && shapedReqInScope.length === 0) {
1278
+ const treeWideFailure = treeWideFailureEnvelope(
1279
+ 'D3', gate,
1280
+ [
1281
+ { name: 'shapes:draftSettled', check: draftCheck },
1282
+ { name: 'shapes:entityJoin', check: entityJoinCheck },
1283
+ ],
1284
+ { currentTreeHash: ctx.currentTreeHash ?? null, freezeGates: /** @type {any} */ (ctx.freeze?.gates) },
1285
+ );
1286
+ if (treeWideFailure) return treeWideFailure;
1287
+ // Ruling R2 2026-10-05: `[draft]` is tree-wide (not a D3 scope
1288
+ // input), so the pre-R2 reason trailer "and no [draft] core entity
1289
+ // in scope" was stale -- the applicability condition is just the
1290
+ // two local-scope sets.
1291
+ return notApplicable('D3', gate, 'no TAC and no shaped REQ (httpApi/persistence/auth) in scope');
1062
1292
  }
1063
1293
 
1064
1294
  const checks = [];
@@ -1072,40 +1302,26 @@ export function checkD3Shapes(ctx) {
1072
1302
  noIfaceTacs.map((t) => ({ id: t.tacId, why: 'no interfaces authored' })),
1073
1303
  ));
1074
1304
 
1075
- // Check 2: every interface kind is in the closed vocabulary.
1076
- // Check 3: no description across the three draft homes still
1077
- // carries the `[draft]` marker. A draft shape is entry material
1078
- // the agent pre-populates after L1; the engineer removes the
1079
- // marker when the shape is settled. `tacHasInterface` and
1080
- // `kindVocabulary` apply equally to drafts.
1305
+ // Check 2: every interface kind is in the closed vocabulary (over
1306
+ // delta; drafts included).
1081
1307
  let totalInterfaces = 0;
1082
1308
  const badKinds = [];
1083
- const draftFindings = [];
1084
1309
  for (const tac of tacsInScope) {
1085
- if (parseTacPurposeDraft(tac)) {
1086
- draftFindings.push({ id: tac.tacId, why: 'draft TAC purpose pre-populated at L1, not yet settled' });
1087
- }
1088
1310
  for (const iface of tac.interfaces ?? []) {
1089
1311
  totalInterfaces += 1;
1090
1312
  const kind = iface?.kind;
1091
1313
  if (typeof kind !== 'string' || !INTERFACE_KINDS_SET.has(kind)) {
1092
1314
  badKinds.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: `unknown interface kind ${JSON.stringify(kind)}` });
1093
1315
  }
1094
- if (parseInterfaceDraft(iface)) {
1095
- draftFindings.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: 'draft interface pre-populated at L1, not yet settled' });
1096
- }
1097
1316
  }
1098
1317
  }
1099
- for (const entity of draftEntities) {
1100
- draftFindings.push({ id: `TAD.entity:${entity?.name ?? '(unnamed)'}`, why: 'draft core entity pre-populated at L1, not yet settled' });
1101
- }
1102
- // Total for the draft check is every draft home that could carry
1103
- // the marker on an in-scope TAC plus every core entity (tree-wide):
1104
- // the TAC itself (its purpose) + every interface on it, plus the
1105
- // coreEntities list.
1106
- const totalDraftHomes = tacsInScope.length + totalInterfaces + coreEntities.length;
1107
1318
  checks.push(makeCheck('shapes:kindVocabulary', 'delta', totalInterfaces, badKinds));
1108
- checks.push(makeCheck('shapes:draftSettled', 'delta', totalDraftHomes, draftFindings));
1319
+
1320
+ // Check 3: shapes:draftSettled. Ruling R2 2026-10-05: tree-wide (a
1321
+ // [draft] anywhere is unfinished work). Computed by
1322
+ // computeDraftSettledCheck so the same check is reused ahead of the
1323
+ // scope early-return.
1324
+ checks.push(draftCheck);
1109
1325
 
1110
1326
  // ADR-4131 (0.30.0 PR 5): three D3 bite checks follow.
1111
1327
  // Check 4: shapes:templateMarkers (engineer, over delta). For every
@@ -1132,46 +1348,10 @@ export function checkD3Shapes(ctx) {
1132
1348
  }
1133
1349
  checks.push(makeCheck('shapes:templateMarkers', 'delta', totalInterfaces, templateFindings));
1134
1350
 
1135
- // Check 5: shapes:entityJoin (engineer, over tree). Every entry in
1136
- // TAD.dataArchitecture.coreEntities must be named by exactly one
1137
- // recordShape across the whole tree (not just the delta scope),
1138
- // matching on the recordShape's `name` field or on an `entity:` line
1139
- // in its description. A `[draft]` prefix is stripped before the
1140
- // entity-line scan so a draft recordShape still joins.
1141
- /** @type {Array<{ id: string, why: string }>} */
1142
- const joinFindings = [];
1143
- const treeTacs = tree.tacs ?? [];
1144
- const recordShapesByEntity = new Map();
1145
- for (const tac of treeTacs) {
1146
- for (const iface of tac.interfaces ?? []) {
1147
- if (iface?.kind !== 'recordShape') continue;
1148
- const ownerLabel = `${tac.tacId}:${iface?.name ?? '(unnamed)'}`;
1149
- const names = new Set();
1150
- if (typeof iface?.name === 'string') names.add(iface.name);
1151
- const desc = typeof iface?.description === 'string' ? stripDraftPrefix(iface.description) : '';
1152
- const entityRe = /(^|\n)\s*(?:[-*]\s*)?entity\s*[:=]\s*["'`]?([A-Za-z_][A-Za-z0-9_]*)/g;
1153
- let em;
1154
- while ((em = entityRe.exec(desc)) !== null) {
1155
- if (em[2]) names.add(em[2]);
1156
- }
1157
- for (const name of names) {
1158
- const owners = recordShapesByEntity.get(name) ?? [];
1159
- owners.push(ownerLabel);
1160
- recordShapesByEntity.set(name, owners);
1161
- }
1162
- }
1163
- }
1164
- for (const entity of coreEntities) {
1165
- const name = typeof entity?.name === 'string' ? entity.name : null;
1166
- if (!name) continue;
1167
- const owners = recordShapesByEntity.get(name) ?? [];
1168
- if (owners.length === 0) {
1169
- joinFindings.push({ id: `TAD.entity:${name}`, why: 'no record shape' });
1170
- } else if (owners.length > 1) {
1171
- joinFindings.push({ id: `TAD.entity:${name}`, why: `${owners.length} record shapes` });
1172
- }
1173
- }
1174
- checks.push(makeCheck('shapes:entityJoin', 'tree', coreEntities.length, joinFindings));
1351
+ // Check 5: shapes:entityJoin. Ruling R5 2026-10-05: tree-wide by
1352
+ // definition. Computed by computeEntityJoinCheck so the same check
1353
+ // is reused ahead of the scope early-return (ADR-4138).
1354
+ checks.push(entityJoinCheck);
1175
1355
 
1176
1356
  // Check 6: shapes:pathsResolve (engineer, over delta). Any path:
1177
1357
  // token on an in-scope interface description must either resolve
@@ -1363,27 +1543,17 @@ export function checkD4Stories(ctx) {
1363
1543
  // ---------------------------------------------------------------------------
1364
1544
 
1365
1545
  /**
1366
- * D5 -- Cross-cutting weave. 0.29.0 runs cheap presence checks.
1367
- *
1368
- * SEAM 0.30: full concern catalogue with per-shape applicability;
1369
- * per-(concern, REQ) pair enumeration; TAD securityArchitecture and
1370
- * operationalConcerns structure checks beyond presence.
1546
+ * Compute the `crosscut:securityArchitecture` check (engineer,
1547
+ * tree-wide). Pure. Ruling R13 2026-10-05 (ADR-4138 extended): if the
1548
+ * tree carries any REQ with shape `auth` or `httpApi`, TAD.securityArchitecture
1549
+ * must be non-empty. Runs ahead of the D5 scope early-return so a
1550
+ * narrowed scope that excludes the auth REQ still reports the finding.
1371
1551
  *
1372
1552
  * @param {StageContext} ctx
1553
+ * @returns {ReturnType<typeof makeCheck>}
1373
1554
  */
1374
- export function checkD5Crosscut(ctx) {
1375
- const gate = STAGE_GATES.D5;
1376
- const scope = asSet(ctx.scope);
1555
+ function computeSecurityArchitectureCheck(ctx) {
1377
1556
  const tree = ctx.tree;
1378
- const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
1379
-
1380
- if (reqInScope.length === 0) {
1381
- return notApplicable('D5', gate, 'no REQ in scope');
1382
- }
1383
-
1384
- const checks = [];
1385
-
1386
- // Check 1: TAD.securityArchitecture non-empty when any REQ carries shape 'auth' or 'httpApi'.
1387
1557
  const authApi = (tree.requirements ?? []).some((r) => {
1388
1558
  const s = r.shapeClassification?.shapes ?? [];
1389
1559
  return s.includes('auth') || s.includes('httpApi');
@@ -1396,11 +1566,22 @@ export function checkD5Crosscut(ctx) {
1396
1566
  secFail.push({ id: 'TAD.securityArchitecture', why: 'empty while auth or httpApi REQ exists' });
1397
1567
  }
1398
1568
  }
1399
- checks.push(makeCheck('crosscut:securityArchitecture', 'tree', authApi ? 1 : 0, secFail));
1569
+ return makeCheck('crosscut:securityArchitecture', 'tree', authApi ? 1 : 0, secFail);
1570
+ }
1400
1571
 
1401
- // Check 2: TAD.operationalConcerns non-empty when any AC description
1402
- // carries the '[deployed]' marker (a cheap presence signal that
1403
- // proposal §3.2 D5 lists as 'deployed-scope AC').
1572
+ /**
1573
+ * Compute the `crosscut:operationalConcerns` check (engineer,
1574
+ * tree-wide). Pure. Ruling R13 2026-10-05 (ADR-4138 extended): if any
1575
+ * AC description tree-wide carries the `[deployed]` marker, then
1576
+ * TAD.operationalConcerns must be non-empty. Runs ahead of the D5
1577
+ * scope early-return.
1578
+ *
1579
+ * @param {StageContext} ctx
1580
+ * @returns {ReturnType<typeof makeCheck>}
1581
+ */
1582
+ function computeOperationalConcernsCheck(ctx) {
1583
+ const tree = ctx.tree;
1584
+ const tad = /** @type {any} */ (tree.tad ?? {});
1404
1585
  const hasDeployedAc = (tree.userStories ?? []).some((us) => (us.acceptanceCriteria ?? []).some((ac) => typeof ac?.description === 'string' && ac.description.includes('[deployed]')));
1405
1586
  const opFail = [];
1406
1587
  if (hasDeployedAc) {
@@ -1409,7 +1590,59 @@ export function checkD5Crosscut(ctx) {
1409
1590
  opFail.push({ id: 'TAD.operationalConcerns', why: 'empty while deployed-scope AC exists' });
1410
1591
  }
1411
1592
  }
1412
- checks.push(makeCheck('crosscut:operationalConcerns', 'tree', hasDeployedAc ? 1 : 0, opFail));
1593
+ return makeCheck('crosscut:operationalConcerns', 'tree', hasDeployedAc ? 1 : 0, opFail);
1594
+ }
1595
+
1596
+ /**
1597
+ * D5 -- Cross-cutting weave. 0.29.0 runs cheap presence checks.
1598
+ *
1599
+ * SEAM 0.30: full concern catalogue with per-shape applicability;
1600
+ * per-(concern, REQ) pair enumeration; TAD securityArchitecture and
1601
+ * operationalConcerns structure checks beyond presence.
1602
+ *
1603
+ * @param {StageContext} ctx
1604
+ */
1605
+ export function checkD5Crosscut(ctx) {
1606
+ const gate = STAGE_GATES.D5;
1607
+ const scope = asSet(ctx.scope);
1608
+ const tree = ctx.tree;
1609
+ const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
1610
+
1611
+ // Ruling R13 2026-10-05 (ADR-4138 extended, PR 292 follow-up):
1612
+ // crosscut:securityArchitecture and crosscut:operationalConcerns are
1613
+ // tree-wide checks; evaluate them before any scope early-return so a
1614
+ // narrowed D5 scope that excludes the triggering REQ or AC still
1615
+ // reports an empty TAD.securityArchitecture / TAD.operationalConcerns.
1616
+ const securityCheck = computeSecurityArchitectureCheck(ctx);
1617
+ const operationalCheck = computeOperationalConcernsCheck(ctx);
1618
+
1619
+ if (reqInScope.length === 0) {
1620
+ const treeWideFailure = treeWideFailureEnvelope(
1621
+ 'D5', gate,
1622
+ [
1623
+ { name: 'crosscut:securityArchitecture', check: securityCheck },
1624
+ { name: 'crosscut:operationalConcerns', check: operationalCheck },
1625
+ ],
1626
+ { currentTreeHash: ctx.currentTreeHash ?? null, freezeGates: /** @type {any} */ (ctx.freeze?.gates) },
1627
+ );
1628
+ if (treeWideFailure) return treeWideFailure;
1629
+ return notApplicable('D5', gate, 'no REQ in scope');
1630
+ }
1631
+
1632
+ const checks = [];
1633
+
1634
+ // Check 1: TAD.securityArchitecture non-empty when any REQ carries
1635
+ // shape 'auth' or 'httpApi'. Ruling R13 2026-10-05: tree-wide;
1636
+ // computed by computeSecurityArchitectureCheck so the same check is
1637
+ // reused ahead of the scope early-return (ADR-4138 extended).
1638
+ checks.push(securityCheck);
1639
+
1640
+ // Check 2: TAD.operationalConcerns non-empty when any AC description
1641
+ // carries the '[deployed]' marker (a cheap presence signal that
1642
+ // proposal §3.2 D5 lists as 'deployed-scope AC'). Ruling R13
1643
+ // 2026-10-05: tree-wide; computed by computeOperationalConcernsCheck
1644
+ // so the same check is reused ahead of the scope early-return.
1645
+ checks.push(operationalCheck);
1413
1646
 
1414
1647
  // Check 3: no open concern-ledger entry on a REQ in scope.
1415
1648
  const concerns = /** @type {any} */ (ctx.ledgers?.concerns);
@@ -1831,10 +2064,24 @@ function extractThenFieldTokens(thenClause) {
1831
2064
  /**
1832
2065
  * Collect every interface the tree "reaches" from an AC `ownerRef` or
1833
2066
  * a REQ `deliveredBy` (REQ-174 companion field). The result is a Set
1834
- * of `<tacId>:<name>` keys. A `deliveredBy` on a REQ can take two
1835
- * shapes: an array of `TAC-nnnn:<name>` pointer strings, or an array
1836
- * of plain TAC ids ("delivered by this TAC"); the second shape reaches
1837
- * every interface on the named TAC.
2067
+ * of `<tacId>:<name>` keys.
2068
+ *
2069
+ * REQ `deliveredBy` takes two canonical shapes, both accepted here:
2070
+ * 1. The schema-canonical object form `{tacId, adrId?, field?}`
2071
+ * (`@stravica-ai/rcf-schemas` 0.6.3 `$defs.deliveredBy`). When
2072
+ * `tacId` is present:
2073
+ * - `field: interfaces[<name>]` reaches `{tacId}:{name}` (one
2074
+ * interface), via `parseOwnerRefInterfaceField`.
2075
+ * - any other `field`, or no `field`, reaches every interface
2076
+ * on the named TAC (the "delivered by this TAC" meaning).
2077
+ * An `adrId`-only deliveredBy does not reach any TAC interface.
2078
+ * 2. A legacy array of pointer strings: `TAC-nnnn:<name>` reaches
2079
+ * one interface, a bare `TAC-nnnn` reaches every interface on
2080
+ * that TAC.
2081
+ *
2082
+ * Issue 301 (2026-10-06): the array-only guard dropped every
2083
+ * schema-canonical object deliveredBy and false-positived every
2084
+ * blueprint TAC interface.
1838
2085
  *
1839
2086
  * @param {import('#core/store/walker.js').TreeModel} tree
1840
2087
  * @returns {Set<string>}
@@ -1852,19 +2099,40 @@ function collectReachedInterfaces(tree) {
1852
2099
  out.add(`${ref.tacId}:${name}`);
1853
2100
  }
1854
2101
  }
1855
- // REQ deliveredBy (optional field; shapes above).
2102
+ // REQ deliveredBy. Both shapes documented above.
1856
2103
  for (const req of tree.requirements ?? []) {
1857
2104
  const deliveredBy = /** @type {any} */ (req)?.deliveredBy;
1858
- if (!Array.isArray(deliveredBy)) continue;
1859
- for (const entry of deliveredBy) {
1860
- if (typeof entry !== 'string' || entry.length === 0) continue;
1861
- const m = entry.match(/^(TAC-[A-Za-z0-9-]+):(.+)$/);
1862
- if (m) {
1863
- out.add(`${m[1]}:${m[2]}`);
2105
+ if (deliveredBy == null) continue;
2106
+ if (Array.isArray(deliveredBy)) {
2107
+ for (const entry of deliveredBy) {
2108
+ if (typeof entry !== 'string' || entry.length === 0) continue;
2109
+ const m = entry.match(/^(TAC-[A-Za-z0-9-]+):(.+)$/);
2110
+ if (m) {
2111
+ out.add(`${m[1]}:${m[2]}`);
2112
+ continue;
2113
+ }
2114
+ // Plain TAC id: every interface on that TAC is reached.
2115
+ const tac = (tree.tacs ?? []).find((t) => t?.tacId === entry);
2116
+ if (!tac) continue;
2117
+ for (const iface of tac.interfaces ?? []) {
2118
+ if (typeof iface?.name === 'string') out.add(`${tac.tacId}:${iface.name}`);
2119
+ }
2120
+ }
2121
+ continue;
2122
+ }
2123
+ if (typeof deliveredBy === 'object') {
2124
+ const tacId = /** @type {any} */ (deliveredBy).tacId;
2125
+ if (typeof tacId !== 'string' || tacId.length === 0) continue;
2126
+ const field = /** @type {any} */ (deliveredBy).field;
2127
+ const name = parseOwnerRefInterfaceField(field);
2128
+ if (name) {
2129
+ out.add(`${tacId}:${name}`);
1864
2130
  continue;
1865
2131
  }
1866
- // Plain TAC id: every interface on that TAC is reached.
1867
- const tac = (tree.tacs ?? []).find((t) => t?.tacId === entry);
2132
+ // No field, or a field that does not name an interface: reach
2133
+ // every interface on the named TAC (the "delivered by this TAC"
2134
+ // meaning carried over from the array-shape bare TAC pointer).
2135
+ const tac = (tree.tacs ?? []).find((t) => t?.tacId === tacId);
1868
2136
  if (!tac) continue;
1869
2137
  for (const iface of tac.interfaces ?? []) {
1870
2138
  if (typeof iface?.name === 'string') out.add(`${tac.tacId}:${iface.name}`);