@starci/skills 1.1.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 (321) hide show
  1. package/INDEX.md +74 -0
  2. package/INDEX.vi.md +75 -0
  3. package/README.md +44 -0
  4. package/README.vi.md +43 -0
  5. package/SKILL.md +135 -0
  6. package/SKILL.vi.md +128 -0
  7. package/alias/INDEX.md +104 -0
  8. package/alias/INDEX.vi.md +104 -0
  9. package/alias/alias.json +297 -0
  10. package/bin/starci-skills.mjs +219 -0
  11. package/knowledge/INDEX.md +22 -0
  12. package/knowledge/INDEX.vi.md +22 -0
  13. package/knowledge/grammars/starci/DNA.md +146 -0
  14. package/knowledge/grammars/starci/DNA.vi.md +146 -0
  15. package/knowledge/grammars/starci/INDEX.md +25 -0
  16. package/knowledge/grammars/starci/INDEX.vi.md +25 -0
  17. package/knowledge/grammars/starci/family.md +50 -0
  18. package/knowledge/grammars/starci/family.vi.md +50 -0
  19. package/knowledge/grammars/starci/idioms.md +120 -0
  20. package/knowledge/grammars/starci/idioms.vi.md +121 -0
  21. package/knowledge/grammars/starci/playbook.md +36 -0
  22. package/knowledge/grammars/starci/playbook.vi.md +35 -0
  23. package/knowledge/patterns/be/INDEX.md +29 -0
  24. package/knowledge/patterns/be/INDEX.vi.md +29 -0
  25. package/knowledge/patterns/be/comment.md +80 -0
  26. package/knowledge/patterns/be/comment.vi.md +80 -0
  27. package/knowledge/patterns/be/error.md +86 -0
  28. package/knowledge/patterns/be/error.vi.md +86 -0
  29. package/knowledge/patterns/be/folder.md +87 -0
  30. package/knowledge/patterns/be/folder.vi.md +86 -0
  31. package/knowledge/patterns/be/function.md +80 -0
  32. package/knowledge/patterns/be/function.vi.md +80 -0
  33. package/knowledge/patterns/be/imports.md +79 -0
  34. package/knowledge/patterns/be/imports.vi.md +79 -0
  35. package/knowledge/patterns/be/naming.md +87 -0
  36. package/knowledge/patterns/be/naming.vi.md +87 -0
  37. package/knowledge/patterns/be/test.md +79 -0
  38. package/knowledge/patterns/be/test.vi.md +79 -0
  39. package/knowledge/patterns/be/typing.md +73 -0
  40. package/knowledge/patterns/be/typing.vi.md +73 -0
  41. package/knowledge/patterns/fe/INDEX.md +29 -0
  42. package/knowledge/patterns/fe/INDEX.vi.md +29 -0
  43. package/knowledge/patterns/fe/comment.md +70 -0
  44. package/knowledge/patterns/fe/comment.vi.md +70 -0
  45. package/knowledge/patterns/fe/error.md +61 -0
  46. package/knowledge/patterns/fe/error.vi.md +61 -0
  47. package/knowledge/patterns/fe/folder.md +98 -0
  48. package/knowledge/patterns/fe/folder.vi.md +95 -0
  49. package/knowledge/patterns/fe/function.md +70 -0
  50. package/knowledge/patterns/fe/function.vi.md +70 -0
  51. package/knowledge/patterns/fe/imports.md +87 -0
  52. package/knowledge/patterns/fe/imports.vi.md +87 -0
  53. package/knowledge/patterns/fe/naming.md +77 -0
  54. package/knowledge/patterns/fe/naming.vi.md +77 -0
  55. package/knowledge/patterns/fe/test.md +73 -0
  56. package/knowledge/patterns/fe/test.vi.md +73 -0
  57. package/knowledge/patterns/fe/typing.md +67 -0
  58. package/knowledge/patterns/fe/typing.vi.md +67 -0
  59. package/knowledge/ui/INDEX.md +101 -0
  60. package/knowledge/ui/INDEX.vi.md +101 -0
  61. package/knowledge/ui/composition/INDEX.md +60 -0
  62. package/knowledge/ui/composition/INDEX.vi.md +63 -0
  63. package/knowledge/ui/composition/accent.md +73 -0
  64. package/knowledge/ui/composition/accent.vi.md +74 -0
  65. package/knowledge/ui/composition/action.md +55 -0
  66. package/knowledge/ui/composition/action.vi.md +56 -0
  67. package/knowledge/ui/composition/coverage.md +37 -0
  68. package/knowledge/ui/composition/coverage.vi.md +37 -0
  69. package/knowledge/ui/composition/cta.md +79 -0
  70. package/knowledge/ui/composition/cta.vi.md +79 -0
  71. package/knowledge/ui/composition/feedback.md +55 -0
  72. package/knowledge/ui/composition/feedback.vi.md +56 -0
  73. package/knowledge/ui/composition/hierarchy.md +83 -0
  74. package/knowledge/ui/composition/hierarchy.vi.md +82 -0
  75. package/knowledge/ui/composition/layout.md +91 -0
  76. package/knowledge/ui/composition/layout.vi.md +93 -0
  77. package/knowledge/ui/composition/responsive.md +67 -0
  78. package/knowledge/ui/composition/responsive.vi.md +67 -0
  79. package/knowledge/ui/composition/state.md +105 -0
  80. package/knowledge/ui/composition/state.vi.md +106 -0
  81. package/knowledge/ui/presentation/INDEX.md +94 -0
  82. package/knowledge/ui/presentation/INDEX.vi.md +69 -0
  83. package/knowledge/ui/presentation/boundary.md +170 -0
  84. package/knowledge/ui/presentation/boundary.vi.md +169 -0
  85. package/knowledge/ui/presentation/font.md +155 -0
  86. package/knowledge/ui/presentation/font.vi.md +156 -0
  87. package/knowledge/ui/presentation/gap.md +181 -0
  88. package/knowledge/ui/presentation/gap.vi.md +181 -0
  89. package/knowledge/ui/presentation/margin.md +168 -0
  90. package/knowledge/ui/presentation/margin.vi.md +171 -0
  91. package/knowledge/ui/presentation/measure.md +174 -0
  92. package/knowledge/ui/presentation/measure.vi.md +178 -0
  93. package/knowledge/ui/presentation/overflow.md +142 -0
  94. package/knowledge/ui/presentation/overflow.vi.md +142 -0
  95. package/knowledge/ui/presentation/padding.md +293 -0
  96. package/knowledge/ui/presentation/padding.vi.md +292 -0
  97. package/knowledge/ui/presentation/surface.md +166 -0
  98. package/knowledge/ui/presentation/surface.vi.md +166 -0
  99. package/knowledge/ui/presentation/text-flow.md +123 -0
  100. package/knowledge/ui/presentation/text-flow.vi.md +123 -0
  101. package/knowledge/ui/presentation/tone.md +114 -0
  102. package/knowledge/ui/presentation/tone.vi.md +113 -0
  103. package/knowledge/ui/proof/INDEX.md +55 -0
  104. package/knowledge/ui/proof/INDEX.vi.md +55 -0
  105. package/knowledge/ui/proof/accessibility.md +67 -0
  106. package/knowledge/ui/proof/accessibility.vi.md +68 -0
  107. package/knowledge/ui/proof/contrast.md +58 -0
  108. package/knowledge/ui/proof/contrast.vi.md +57 -0
  109. package/knowledge/ui/proof/focus.md +76 -0
  110. package/knowledge/ui/proof/focus.vi.md +76 -0
  111. package/knowledge/ui/proof/motion.md +63 -0
  112. package/knowledge/ui/proof/motion.vi.md +64 -0
  113. package/knowledge/ui/proof/render-truth.md +63 -0
  114. package/knowledge/ui/proof/render-truth.vi.md +63 -0
  115. package/operators/INDEX.md +199 -0
  116. package/operators/INDEX.vi.md +199 -0
  117. package/operators/architecture-decide/errors.json +115 -0
  118. package/operators/architecture-decide/operator.json +20 -0
  119. package/operators/architecture-decide/operator.md +133 -0
  120. package/operators/architecture-decide/operator.vi.md +130 -0
  121. package/operators/architecture-decide/self-test.mjs +221 -0
  122. package/operators/architecture-decide/validate.mjs +112 -0
  123. package/operators/backend-source-apply/errors.json +66 -0
  124. package/operators/backend-source-apply/operator.json +20 -0
  125. package/operators/backend-source-apply/operator.md +189 -0
  126. package/operators/backend-source-apply/operator.vi.md +185 -0
  127. package/operators/backend-source-apply/self-test.mjs +257 -0
  128. package/operators/backend-source-apply/validate.mjs +240 -0
  129. package/operators/business-decide/errors.json +78 -0
  130. package/operators/business-decide/operator.json +20 -0
  131. package/operators/business-decide/operator.md +176 -0
  132. package/operators/business-decide/operator.vi.md +174 -0
  133. package/operators/business-decide/self-test.mjs +225 -0
  134. package/operators/business-decide/validate.mjs +277 -0
  135. package/operators/content-generate/errors.json +106 -0
  136. package/operators/content-generate/operator.json +21 -0
  137. package/operators/content-generate/operator.md +155 -0
  138. package/operators/content-generate/operator.vi.md +155 -0
  139. package/operators/content-generate/self-test.mjs +288 -0
  140. package/operators/content-generate/validate.mjs +196 -0
  141. package/operators/errors.json +178 -0
  142. package/operators/frontend-direction-decide/errors.json +135 -0
  143. package/operators/frontend-direction-decide/operator.json +21 -0
  144. package/operators/frontend-direction-decide/operator.md +167 -0
  145. package/operators/frontend-direction-decide/operator.vi.md +167 -0
  146. package/operators/frontend-direction-decide/self-test.mjs +226 -0
  147. package/operators/frontend-direction-decide/validate.mjs +146 -0
  148. package/operators/frontend-presentation-resolve/errors.json +42 -0
  149. package/operators/frontend-presentation-resolve/operator.json +19 -0
  150. package/operators/frontend-presentation-resolve/operator.md +142 -0
  151. package/operators/frontend-presentation-resolve/operator.vi.md +140 -0
  152. package/operators/frontend-presentation-resolve/self-test.mjs +163 -0
  153. package/operators/frontend-presentation-resolve/validate.mjs +143 -0
  154. package/operators/frontend-source-apply/errors.json +30 -0
  155. package/operators/frontend-source-apply/operator.json +20 -0
  156. package/operators/frontend-source-apply/operator.md +127 -0
  157. package/operators/frontend-source-apply/operator.vi.md +126 -0
  158. package/operators/frontend-source-apply/self-test.mjs +214 -0
  159. package/operators/frontend-source-apply/validate.mjs +133 -0
  160. package/operators/frontend-surface-audit/errors.json +5 -0
  161. package/operators/frontend-surface-audit/operator.json +22 -0
  162. package/operators/frontend-surface-audit/operator.md +121 -0
  163. package/operators/frontend-surface-audit/operator.vi.md +121 -0
  164. package/operators/frontend-surface-audit/self-test.mjs +185 -0
  165. package/operators/frontend-surface-audit/validate.mjs +127 -0
  166. package/operators/git-publish/errors.json +54 -0
  167. package/operators/git-publish/operator.json +20 -0
  168. package/operators/git-publish/operator.md +180 -0
  169. package/operators/git-publish/operator.vi.md +178 -0
  170. package/operators/git-publish/self-test.mjs +177 -0
  171. package/operators/git-publish/validate.mjs +106 -0
  172. package/operators/platform-operate/errors.json +90 -0
  173. package/operators/platform-operate/operator.json +22 -0
  174. package/operators/platform-operate/operator.md +162 -0
  175. package/operators/platform-operate/operator.vi.md +160 -0
  176. package/operators/platform-operate/self-test.mjs +202 -0
  177. package/operators/platform-operate/validate.mjs +193 -0
  178. package/operators/quality-verify/errors.json +54 -0
  179. package/operators/quality-verify/operator.json +20 -0
  180. package/operators/quality-verify/operator.md +185 -0
  181. package/operators/quality-verify/operator.vi.md +177 -0
  182. package/operators/quality-verify/self-test.mjs +210 -0
  183. package/operators/quality-verify/validate.mjs +197 -0
  184. package/operators/release-deploy/errors.json +158 -0
  185. package/operators/release-deploy/operator.json +23 -0
  186. package/operators/release-deploy/operator.md +183 -0
  187. package/operators/release-deploy/operator.vi.md +181 -0
  188. package/operators/release-deploy/self-test.mjs +235 -0
  189. package/operators/release-deploy/validate.mjs +129 -0
  190. package/operators/uat-verify/errors.json +78 -0
  191. package/operators/uat-verify/operator.json +25 -0
  192. package/operators/uat-verify/operator.md +162 -0
  193. package/operators/uat-verify/operator.vi.md +161 -0
  194. package/operators/uat-verify/self-test.mjs +270 -0
  195. package/operators/uat-verify/validate.mjs +202 -0
  196. package/operators/workspace-bind/errors.json +90 -0
  197. package/operators/workspace-bind/operator.json +21 -0
  198. package/operators/workspace-bind/operator.md +148 -0
  199. package/operators/workspace-bind/operator.vi.md +148 -0
  200. package/operators/workspace-bind/self-test.mjs +205 -0
  201. package/operators/workspace-bind/validate.mjs +152 -0
  202. package/package.json +50 -0
  203. package/readiness/initialization/workspaces/commit-policy.json +91 -0
  204. package/readiness/initialization/workspaces/config.schema.json +35 -0
  205. package/readiness/initialization/workspaces/device-state.schema.json +54 -0
  206. package/readiness/initialization/workspaces/local-route.schema.json +206 -0
  207. package/readiness/initialization/workspaces/portable-route.schema.json +200 -0
  208. package/resources/INDEX.md +96 -0
  209. package/resources/INDEX.vi.md +99 -0
  210. package/resources/agents/profiles/claude.json +131 -0
  211. package/resources/agents/profiles/openai.json +131 -0
  212. package/resources/orchestrator.json +71 -0
  213. package/resources/tools.json +85 -0
  214. package/routing.json +272 -0
  215. package/scripts/alias-registry.mjs +31 -0
  216. package/scripts/device-state.mjs +497 -0
  217. package/scripts/device-state.spec.mjs +18 -0
  218. package/scripts/errors-registry.mjs +68 -0
  219. package/scripts/generate-alias-doc.mjs +66 -0
  220. package/scripts/generate-grammar-dna.mjs +387 -0
  221. package/scripts/generate-operators-index.mjs +95 -0
  222. package/scripts/generate-presentation-owned.mjs +681 -0
  223. package/scripts/install-cli.spec.mjs +74 -0
  224. package/scripts/json-schema.mjs +94 -0
  225. package/scripts/operator-md.mjs +96 -0
  226. package/scripts/run-operator-self-tests.mjs +36 -0
  227. package/scripts/validate-alias.mjs +165 -0
  228. package/scripts/validate-defaults.mjs +72 -0
  229. package/scripts/validate-knowledge-citations.mjs +90 -0
  230. package/scripts/validate-operator.mjs +125 -0
  231. package/scripts/validate-request.mjs +80 -0
  232. package/scripts/validate-resources.mjs +117 -0
  233. package/scripts/validate-response.mjs +141 -0
  234. package/scripts/validate-routing.mjs +91 -0
  235. package/scripts/validate-step.mjs +50 -0
  236. package/scripts/validate-templates.mjs +226 -0
  237. package/scripts/validate-templates.spec.mjs +144 -0
  238. package/scripts/validate-workflows.mjs +106 -0
  239. package/scripts/workspace-portable.mjs +389 -0
  240. package/scripts/workspace-portable.spec.mjs +246 -0
  241. package/templates/README.md +47 -0
  242. package/templates/README.vi.md +45 -0
  243. package/templates/changes.example.md +27 -0
  244. package/templates/grammars.template.md +34 -0
  245. package/templates/kinds/architecture-decision.contract.json +14 -0
  246. package/templates/kinds/architecture-decision.skeleton.md +52 -0
  247. package/templates/kinds/backend-source-application.contract.json +10 -0
  248. package/templates/kinds/backend-source-application.skeleton.md +34 -0
  249. package/templates/kinds/business-promise-authority.contract.json +13 -0
  250. package/templates/kinds/business-promise-authority.skeleton.md +67 -0
  251. package/templates/kinds/capture.schema.json +33 -0
  252. package/templates/kinds/changes.contract.json +10 -0
  253. package/templates/kinds/changes.skeleton.md +26 -0
  254. package/templates/kinds/checks.schema.json +44 -0
  255. package/templates/kinds/claims.schema.json +165 -0
  256. package/templates/kinds/conformance.schema.json +15 -0
  257. package/templates/kinds/content-brief.contract.json +12 -0
  258. package/templates/kinds/content-brief.skeleton.md +45 -0
  259. package/templates/kinds/content-generation-receipt.contract.json +11 -0
  260. package/templates/kinds/content-generation-receipt.skeleton.md +40 -0
  261. package/templates/kinds/content-review.contract.json +11 -0
  262. package/templates/kinds/content-review.skeleton.md +45 -0
  263. package/templates/kinds/contract.schema.json +28 -0
  264. package/templates/kinds/coverage-matrix.schema.json +86 -0
  265. package/templates/kinds/coverage.schema.json +62 -0
  266. package/templates/kinds/current-state.schema.json +40 -0
  267. package/templates/kinds/delta.schema.json +95 -0
  268. package/templates/kinds/e2e.schema.json +63 -0
  269. package/templates/kinds/frontend-direction-decision.contract.json +93 -0
  270. package/templates/kinds/frontend-direction-decision.skeleton.md +68 -0
  271. package/templates/kinds/frontend-presentation-resolution.contract.json +11 -0
  272. package/templates/kinds/frontend-presentation-resolution.skeleton.md +32 -0
  273. package/templates/kinds/frontend-source-application.contract.json +10 -0
  274. package/templates/kinds/frontend-source-application.skeleton.md +30 -0
  275. package/templates/kinds/frontend-surface-audit.contract.json +52 -0
  276. package/templates/kinds/frontend-surface-audit.skeleton.md +32 -0
  277. package/templates/kinds/gate-result.schema.json +64 -0
  278. package/templates/kinds/git-publication.contract.json +82 -0
  279. package/templates/kinds/git-publication.skeleton.md +61 -0
  280. package/templates/kinds/independent-critique.contract.json +9 -0
  281. package/templates/kinds/independent-critique.skeleton.md +28 -0
  282. package/templates/kinds/inventory.schema.json +35 -0
  283. package/templates/kinds/model.schema.json +99 -0
  284. package/templates/kinds/mutations.schema.json +65 -0
  285. package/templates/kinds/platform-operation-receipt.contract.json +76 -0
  286. package/templates/kinds/platform-operation-receipt.skeleton.md +54 -0
  287. package/templates/kinds/probes.schema.json +130 -0
  288. package/templates/kinds/proof.schema.json +17 -0
  289. package/templates/kinds/quality-verification.contract.json +87 -0
  290. package/templates/kinds/quality-verification.skeleton.md +58 -0
  291. package/templates/kinds/release-deployment.contract.json +85 -0
  292. package/templates/kinds/release-deployment.skeleton.md +67 -0
  293. package/templates/kinds/route.schema.json +293 -0
  294. package/templates/kinds/stack-model.schema.json +90 -0
  295. package/templates/kinds/uat-capture.schema.json +35 -0
  296. package/templates/kinds/uat-flow-verification.contract.json +11 -0
  297. package/templates/kinds/uat-flow-verification.skeleton.md +47 -0
  298. package/templates/kinds/uat-snapshot.schema.json +96 -0
  299. package/templates/kinds/uat-verdicts.schema.json +41 -0
  300. package/templates/kinds/ui-coverage.schema.json +77 -0
  301. package/templates/kinds/verdicts.schema.json +39 -0
  302. package/templates/kinds/workspace-route-binding.contract.json +12 -0
  303. package/templates/kinds/workspace-route-binding.skeleton.md +60 -0
  304. package/templates/kinds/writes.schema.json +37 -0
  305. package/templates/operator.template.md +73 -0
  306. package/templates/patterns.template.md +31 -0
  307. package/templates/step/request.schema.json +42 -0
  308. package/templates/step/response.schema.json +144 -0
  309. package/templates/ui-composition.template.md +36 -0
  310. package/templates/ui-presentation.template.md +57 -0
  311. package/templates/ui-proof.template.md +34 -0
  312. package/workflows/README.md +37 -0
  313. package/workflows/README.vi.md +37 -0
  314. package/workflows/backend-feature.json +59 -0
  315. package/workflows/content-unit.json +19 -0
  316. package/workflows/frontend-new-surface.json +81 -0
  317. package/workflows/frontend-reconstruct.json +67 -0
  318. package/workflows/frontend-refine.json +67 -0
  319. package/workflows/frontend-with-uat.json +78 -0
  320. package/workflows/full-feature.json +104 -0
  321. package/workflows/release.json +29 -0
@@ -0,0 +1,270 @@
1
+ // Proves validate.mjs on a synthetic session branch: a run a person asked for, admitted by both
2
+ // receipts at the pinned commit, with a capture and a screenshot per frozen case, three independent
3
+ // lanes, an appended run record and a moved latest pointer; a behaviour failure that routes to the
4
+ // backend; a UX failure that routes to a person; a run blocked before it published anything; and one
5
+ // mutation per law, each of which must fail with a line that names the defect. Two of the mutations
6
+ // are the custody proof: the placeholder for the shared UAT password may appear in no file this
7
+ // operator writes.
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import path from 'node:path';
12
+ import { validateUatStep } from './validate.mjs';
13
+
14
+ const FEATURE = 'enrollment';
15
+ const FLOW = 'paid-enrollment';
16
+ const RUN = 'run-2026-01-10-1';
17
+ const NS = `uat-${RUN}`;
18
+ const COMMIT = '1'.repeat(40);
19
+ const OTHER_COMMIT = '2'.repeat(40);
20
+ const FP = (c) => `sha256:${String(c).repeat(64)}`;
21
+ const DIR = `.worktrees/uat/${FEATURE}/${FLOW}`;
22
+ const SNAPSHOT_REF = `${DIR}/snapshot.json`;
23
+ const T = (m) => new Date(Date.UTC(2026, 0, 10, 0, m, 0)).toISOString();
24
+ const CASES = ['pay-and-enrol', 'abandon-checkout'];
25
+ const ASSERTIONS = { 'pay-and-enrol': ['entry', 'commitment', 'terminal'], 'abandon-checkout': ['entry', 'recovery'] };
26
+ const AUDIT_IN = 'step-1/parallel-1/response/response.md';
27
+ const QUALITY_IN = 'step-2/parallel-1/response/response.md';
28
+ // The placeholder this operator's law forbids anywhere it writes.
29
+ const LEAK = 'UAT-SHARED-PASSWORD';
30
+
31
+ const frozenCase = (caseId, i, over = {}) => ({ caseId, order: i + 1, assertions: [...ASSERTIONS[caseId]], ...over });
32
+
33
+ function snapshot(over = {}) {
34
+ return {
35
+ runId: RUN, requestedBy: 'the product owner', feature: FEATURE, flow: FLOW, commit: COMMIT, frozenAt: T(10),
36
+ flowRoot: 'PATCHED-BY-WRITE-BRANCH', snapshotRef: SNAPSHOT_REF, snapshotFingerprint: FP(3),
37
+ lease: { leaseRef: `uat-lease://s-test/${FLOW}`, exclusive: true, expiresAt: T(180) },
38
+ admission: [
39
+ { kind: 'frontend-surface-audit', ref: AUDIT_IN, commit: COMMIT },
40
+ { kind: 'quality-verification', ref: QUALITY_IN, commit: COMMIT },
41
+ ],
42
+ account: {
43
+ username: `uat-${FLOW}`, role: 'learner', credentialName: 'uat-shared', credentialRef: '.stacks/local/secrets/uat.enc',
44
+ custody: 'sealed-shared-master-identity', isUat: true, plaintextRecorded: false,
45
+ },
46
+ fixtureNamespace: NS,
47
+ seed: { recordsRef: `${DIR}/seed/records.json`, expectedRef: `${DIR}/seed/expected.json`, fingerprint: FP(4), namespace: NS, isUat: true },
48
+ cases: CASES.map((c, i) => frozenCase(c, i)),
49
+ ...over,
50
+ };
51
+ }
52
+
53
+ const capture = (caseId, over = {}) => ({
54
+ caseId, runId: RUN, order: CASES.indexOf(caseId) + 1, executedAt: T(20 + CASES.indexOf(caseId)),
55
+ screenshotRef: `response/artifacts/${caseId}.png`, loginFieldMasked: true, outcome: 'pass',
56
+ assertions: ASSERTIONS[caseId].map((a) => ({ assertionId: a, lane: a === 'entry' ? 'ui' : 'behavior', observed: `the ${a} state was reached`, evidenceRef: `response/artifacts/${caseId}.png`, outcome: 'pass' })),
57
+ ...over,
58
+ });
59
+
60
+ const lane = (name, verdict = 'pass') => ({ lane: name, verdict, evidenceRefs: [`response/data/captures/${CASES[0]}.json`], statement: `the ${name} lane was judged on its own evidence` });
61
+ const lanes = (over = {}) => ['behavior', 'ux', 'ui'].map((l) => lane(l, over[l] ?? 'pass'));
62
+
63
+ function verdicts(over = {}) {
64
+ return {
65
+ runId: RUN, commit: COMMIT, resultRef: `${DIR}/runs/${RUN}/result.json`, latestRef: `${DIR}/latest`,
66
+ lanes: lanes(), cleanup: { performed: true, isUat: true, namespace: NS, runRecordsDeleted: false },
67
+ ...over,
68
+ };
69
+ }
70
+
71
+ function responseMd({ snap = snapshot(), verd = verdicts(), outcomes = { 'pay-and-enrol': 'pass', 'abandon-checkout': 'pass' }, note = 'The shared credential is named, never printed, and every login field is masked.' } = {}) {
72
+ return `# uat-flow-verification — ${snap.feature}/${snap.flow}
73
+
74
+ The product owner asked for this run; it was admitted at one commit, executed case by case in the
75
+ frozen order, and judged on three independent lanes. ${note}
76
+
77
+ ## Admission
78
+
79
+ | Kind | Ref | Commit |
80
+ | --- | --- | --- |
81
+ ${snap.admission.map((a) => `| \`${a.kind}\` | \`${a.ref}\` | \`${a.commit}\` |`).join('\n')}
82
+
83
+ ## Snapshot
84
+
85
+ | Field | Value |
86
+ | --- | --- |
87
+ | Run | \`${snap.runId}\` |
88
+ | Requested by | ${snap.requestedBy} |
89
+ | Feature | \`${snap.feature}\` |
90
+ | Flow | \`${snap.flow}\` |
91
+ | Commit | \`${snap.commit}\` |
92
+ | Snapshot | \`${snap.snapshotRef}\` |
93
+ | Namespace | \`${snap.fixtureNamespace}\` |
94
+ | Credential | \`${snap.account.credentialRef}\`, resolved by name at login only |
95
+ | Run record | \`${verd.resultRef}\` |
96
+ | Latest | \`${snap.runId}\` |
97
+
98
+ ## Cases
99
+
100
+ | Case | Order | Assertions | Capture | Screenshot | Outcome |
101
+ | --- | --- | --- | --- | --- | --- |
102
+ ${snap.cases.map((c) => `| \`${c.caseId}\` | ${c.order} | ${c.assertions.join(', ')} | \`response/data/captures/${c.caseId}.json\` | \`response/artifacts/${c.caseId}.png\` | ${outcomes[c.caseId] ?? 'pass'} |`).join('\n')}
103
+
104
+ ## Lanes
105
+
106
+ | Lane | Verdict | Evidence |
107
+ | --- | --- | --- |
108
+ ${verd.lanes.map((l) => `| \`${l.lane}\` | ${l.verdict} | \`${l.evidenceRefs[0]}\` |`).join('\n')}
109
+
110
+ ## Fallbacks taken
111
+
112
+ | Code | Action |
113
+ | --- | --- |
114
+ `;
115
+ }
116
+
117
+ const requestJson = ({ extra = {}, inputs, cases = [...CASES] } = {}) => ({
118
+ schemaVersion: 9, operatorId: 'uat.verify', step: 3, parallel: 1, sessionId: 's-test',
119
+ contexts: [{ alias: '@workspaces/be', head: COMMIT }, { alias: '@worktrees/uat/enrollment/paid-enrollment', head: null }],
120
+ requirements: { requestedBy: 'the product owner', feature: FEATURE, flow: FLOW, cases, runId: RUN, lease: `uat-lease://s-test/${FLOW}`, resume: null, ...extra },
121
+ inputs: inputs ?? { 'frontend-surface-audit': AUDIT_IN, 'quality-verification': QUALITY_IN },
122
+ resume: null,
123
+ });
124
+
125
+ function responseJson({ status = 'done', stop, next = ['git.publish'], captures = CASES.map((c) => `response/data/captures/${c}.json`), shots = CASES.map((c) => `response/artifacts/${c}.png`), drop } = {}) {
126
+ const fields = {
127
+ 'uat-flow-verification': 'response/response.md',
128
+ 'uat-snapshot': 'response/data/snapshot.json',
129
+ 'uat-capture': captures,
130
+ 'uat-verdicts': 'response/data/verdicts.json',
131
+ screenshot: shots,
132
+ sheet: 'response/artifacts/sheet.png',
133
+ };
134
+ if (drop) delete fields[drop];
135
+ return { schemaVersion: 9, operatorId: 'uat.verify', step: 3, parallel: 1, status, ...(stop ? { stop } : {}), fallbacks: [], fields, commits: [], next };
136
+ }
137
+
138
+ // history: 'match' appends the record this run publishes; 'none' leaves no flow directory on disk;
139
+ // 'missing-run' leaves the directory without the record; 'rewritten' leaves another result under the
140
+ // same runId; 'stale-latest' points latest at another run.
141
+ function writeBranch(files, history = 'match') {
142
+ const session = mkdtempSync(path.join(tmpdir(), 'uat-session-'));
143
+ const branch = path.join(session, 'step-3', 'parallel-1');
144
+ for (const d of ['request', 'response/data/captures', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
145
+ for (const input of [AUDIT_IN, QUALITY_IN]) {
146
+ mkdirSync(path.dirname(path.join(session, input)), { recursive: true });
147
+ writeFileSync(path.join(session, input), '# admitting receipt\n');
148
+ }
149
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['3/1']], steps: { '3/1': 'uat.verify' }, current: '3/1', status: 'running' }));
150
+ for (const c of CASES) writeFileSync(path.join(branch, 'response', 'artifacts', `${c}.png`), 'png');
151
+ writeFileSync(path.join(branch, 'response', 'artifacts', 'sheet.png'), 'png');
152
+
153
+ const flowRoot = path.join(session, 'flow-root');
154
+ const prepared = { ...files };
155
+ const snap = prepared['response/data/snapshot.json'];
156
+ if (snap && typeof snap === 'object') prepared['response/data/snapshot.json'] = { ...snap, flowRoot };
157
+ for (const [name, content] of Object.entries(prepared)) {
158
+ if (content === null) continue;
159
+ writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
160
+ }
161
+
162
+ if (history !== 'none') {
163
+ mkdirSync(flowRoot, { recursive: true });
164
+ const verd = prepared['response/data/verdicts.json'];
165
+ if (history !== 'missing-run' && verd && typeof verd === 'object') {
166
+ const runDir = path.join(flowRoot, 'runs', verd.runId);
167
+ mkdirSync(runDir, { recursive: true });
168
+ const recorded = history === 'rewritten'
169
+ ? { runId: verd.runId, commit: OTHER_COMMIT, lanes: verd.lanes }
170
+ : { runId: verd.runId, commit: verd.commit, lanes: verd.lanes };
171
+ writeFileSync(path.join(runDir, 'result.json'), JSON.stringify(recorded, null, 2));
172
+ }
173
+ writeFileSync(path.join(flowRoot, 'latest'), history === 'stale-latest' ? 'run-2026-01-09-1' : RUN);
174
+ }
175
+ return { branch, session };
176
+ }
177
+
178
+ const baseline = () => ({
179
+ 'request/request.json': requestJson(),
180
+ 'response/response.json': responseJson(),
181
+ 'response/response.md': responseMd(),
182
+ 'response/data/snapshot.json': snapshot(),
183
+ 'response/data/verdicts.json': verdicts(),
184
+ ...Object.fromEntries(CASES.map((c) => [`response/data/captures/${c}.json`, capture(c)])),
185
+ });
186
+
187
+ function withVerdicts(over, { next, outcomes } = {}) {
188
+ const verd = verdicts({ lanes: lanes(over) });
189
+ return {
190
+ ...baseline(),
191
+ 'response/data/verdicts.json': verd,
192
+ 'response/response.md': responseMd({ verd, outcomes }),
193
+ 'response/response.json': responseJson({ next }),
194
+ };
195
+ }
196
+
197
+ const blocked = () => ({
198
+ 'request/request.json': requestJson(),
199
+ 'response/response.json': { schemaVersion: 9, operatorId: 'uat.verify', step: 3, parallel: 1, status: 'blocked', stop: 'ADMISSION_MISSING', fallbacks: [], fields: {}, commits: [], next: [] },
200
+ });
201
+
202
+ async function expectValid(files, label, history) {
203
+ const { branch, session } = writeBranch(files, history);
204
+ const { errors } = await validateUatStep(branch);
205
+ rmSync(session, { recursive: true, force: true });
206
+ assert.deepEqual(errors, [], `${label} should be valid`);
207
+ }
208
+ async function expectError(files, needle, label, history) {
209
+ const { branch, session } = writeBranch(files, history);
210
+ const { errors } = await validateUatStep(branch);
211
+ rmSync(session, { recursive: true, force: true });
212
+ assert.ok(errors.some((e) => e.includes(needle)), `${label}: expected an error containing "${needle}", got:\n${errors.join('\n') || '(none)'}`);
213
+ }
214
+
215
+ await expectValid(baseline(), 'a run a person asked for, admitted at the pinned commit, three lanes passing');
216
+ await expectValid(withVerdicts({ behavior: 'fail' }, { next: ['backend.source.apply'], outcomes: { 'pay-and-enrol': 'fail', 'abandon-checkout': 'pass' } }), 'a behaviour failure routed to the backend');
217
+ await expectValid(withVerdicts({ ux: 'fail' }, { next: ['user'], outcomes: { 'pay-and-enrol': 'fail', 'abandon-checkout': 'pass' } }), 'a UX failure routed to a person');
218
+ await expectValid(blocked(), 'blocked on a missing admission, publishing nothing', 'none');
219
+
220
+ // The gate and the person behind the run.
221
+ await expectError({ ...baseline(), 'response/response.json': { ...responseJson(), stop: 'LEASE_INVALID' } }, 'only a blocked response carries a stop', 'a done branch carrying a stop');
222
+ await expectValid({ ...blocked(), 'response/response.json': { schemaVersion: 9, operatorId: 'uat.verify', step: 3, parallel: 1, status: 'blocked', stop: 'RUNTIME_UNAVAILABLE', fallbacks: [], fields: {}, commits: [], next: [] } }, 'blocked on the shared RUNTIME_UNAVAILABLE code');
223
+ await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { password: 'x' } }) }, 'requirements.password is not a field', 'a credential has nowhere to go in a request');
224
+ await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { requestedBy: '' } }) }, 'required field requestedBy has no value', 'a run nobody asked for');
225
+ await expectError({ ...baseline(), 'request/request.json': requestJson({ inputs: { 'frontend-surface-audit': AUDIT_IN } }) }, 'required input quality-verification is absent', 'a run admitted by one receipt only');
226
+
227
+ // Admission at the pinned commit.
228
+ await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ admission: [{ kind: 'frontend-surface-audit', ref: AUDIT_IN, commit: COMMIT }, { kind: 'quality-verification', ref: QUALITY_IN, commit: OTHER_COMMIT }] }) }, 'ADMISSION_MISSING — quality-verification was taken at', 'an admission from another commit');
229
+ await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ admission: [{ kind: 'frontend-surface-audit', ref: AUDIT_IN, commit: COMMIT }, { kind: 'frontend-surface-audit', ref: AUDIT_IN, commit: COMMIT }] }) }, 'ADMISSION_MISSING — quality-verification is absent', 'a missing admission');
230
+ await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ admission: [{ kind: 'frontend-surface-audit', ref: 'step-9/parallel-1/response/response.md', commit: COMMIT }, { kind: 'quality-verification', ref: QUALITY_IN, commit: COMMIT }] }) }, 'but the request handed in', 'an admission the request never handed in');
231
+ await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ commit: OTHER_COMMIT }) }, 'but the request pinned @workspaces/be at', 'a snapshot frozen at another commit');
232
+ await expectError({ ...baseline(), 'response/data/verdicts.json': verdicts({ commit: OTHER_COMMIT }) }, 'is not the pinned head', 'a result carrying another commit');
233
+
234
+ // Three lanes, judged apart.
235
+ await expectError({ ...baseline(), 'response/data/verdicts.json': verdicts({ lanes: [lane('behavior'), lane('ux')] }) }, 'the ui lane is missing', 'two lanes instead of three');
236
+ await expectError({ ...baseline(), 'response/data/verdicts.json': verdicts({ lanes: [lane('behavior'), lane('behavior'), lane('ui')] }) }, 'a lane may report at most one verdict', 'one lane judged twice');
237
+ await expectError({ ...baseline(), 'response/data/verdicts.json': verdicts({ lanes: [lane('behavior'), { ...lane('ux'), verdict: 'unavailable' }, lane('ui')] }) }, 'outside the allowed enum', 'a lane that is neither pass nor fail');
238
+ await expectError({ ...baseline(), 'response/data/verdicts.json': verdicts({ lanes: [lane('behavior'), { ...lane('ux'), evidenceRefs: [] }, lane('ui')] }) }, 'array is too short', 'a lane with no evidence');
239
+ await expectError({ ...withVerdicts({ behavior: 'fail' }, { next: ['git.publish'] }) }, 'a failing lane cannot hand to git.publish', 'a failing run handed to publication');
240
+ await expectError({ ...baseline(), 'response/response.json': responseJson({ next: [] }) }, 'hands to git.publish', 'a passing run that routes nowhere');
241
+ await expectError({ ...withVerdicts({ ux: 'fail' }, { next: ['backend.source.apply'] }) }, 'it hands to a person', 'a UX failure routed away from the person');
242
+
243
+ // Evidence per case.
244
+ await expectError({ ...baseline(), 'response/response.json': responseJson({ captures: [`response/data/captures/${CASES[0]}.json`] }), [`response/data/captures/${CASES[1]}.json`]: null }, `case ${CASES[1]} has no capture registered`, 'a frozen case with no capture');
245
+ await expectError({ ...baseline(), 'response/response.json': responseJson({ shots: [`response/artifacts/${CASES[0]}.png`] }) }, `case ${CASES[1]} has no screenshot registered`, 'a frozen case with no screenshot');
246
+ await expectError({ ...baseline(), [`response/data/captures/${CASES[0]}.json`]: capture(CASES[0], { loginFieldMasked: false }) }, 'expected true', 'a capture that cannot say the login field was masked');
247
+ await expectError({ ...baseline(), [`response/data/captures/${CASES[0]}.json`]: capture(CASES[0], { executedAt: T(5) }) }, 'executed at or before the snapshot freeze', 'a case executed before the freeze');
248
+ await expectError({ ...baseline(), [`response/data/captures/${CASES[0]}.json`]: capture(CASES[0], { assertions: capture(CASES[0]).assertions.slice(0, 2) }) }, 'was never observed', 'a frozen assertion nobody observed');
249
+ await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ cases: [frozenCase(CASES[0], 1), frozenCase(CASES[1], 2)] }) }, 'contiguous order starting at 1', 'a frozen order that starts at two');
250
+ await expectError({ ...baseline(), 'request/request.json': requestJson({ cases: [CASES[0], 'ghost-case'] }) }, 'requested case ghost-case was never frozen', 'a case the snapshot never froze');
251
+
252
+ // Custody: the password may appear in nothing this operator writes.
253
+ await expectError({ ...baseline(), 'response/response.md': responseMd({ note: `The tester signed in with ${LEAK} and continued.` }) }, 'the shared UAT password appears in a file this operator writes', 'the password published in the receipt');
254
+ await expectError({ ...baseline(), [`response/data/captures/${CASES[0]}.json`]: capture(CASES[0], { assertions: [{ assertionId: 'entry', lane: 'ui', observed: `the login form held ${LEAK}`, evidenceRef: 'x', outcome: 'pass' }, { assertionId: 'commitment', lane: 'behavior', observed: 'ok', evidenceRef: 'x', outcome: 'pass' }, { assertionId: 'terminal', lane: 'behavior', observed: 'ok', evidenceRef: 'x', outcome: 'pass' }] }) }, 'the shared UAT password appears in a file this operator writes', 'the password recorded in a capture');
255
+ await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ account: { ...snapshot().account, password: 'x' } }) }, 'unexpected property', 'an account record with a place to hold a secret');
256
+ await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ account: { ...snapshot().account, credentialRef: '.stacks/local/secrets/uat.txt' } }) }, 'string does not match', 'a credential outside the sealed file');
257
+
258
+ // Namespace and append-only history.
259
+ await expectError({ ...baseline(), 'response/data/verdicts.json': verdicts({ cleanup: { performed: true, isUat: true, namespace: 'uat-anything', runRecordsDeleted: false } }) }, 'cleanup must name the exact run fixture namespace', 'a cleanup wider than the run');
260
+ await expectError({ ...baseline(), 'response/data/verdicts.json': verdicts({ cleanup: { performed: true, isUat: true, namespace: NS, runRecordsDeleted: true } }) }, 'expected false', 'a cleanup that deleted a run record');
261
+ await expectError(baseline(), 'runs are append-only', 'a run record rewritten with another result', 'rewritten');
262
+ await expectError(baseline(), 'appends its record under runs/', 'a decided run that appended nothing', 'missing-run');
263
+ await expectError(baseline(), 'but this run published', 'a latest pointer left on another run', 'stale-latest');
264
+
265
+ // The receipt agrees with the machine record.
266
+ await expectError({ ...baseline(), 'response/response.md': responseMd().replace(`| Commit | \`${COMMIT}\` |`, `| Commit | \`${OTHER_COMMIT}\` |`) }, 'Snapshot names another commit', 'a receipt naming another commit');
267
+ await expectError({ ...baseline(), 'response/response.md': responseMd().replace('## Cases', '## Case list') }, 'missing section ^## Cases$', 'a receipt section renamed');
268
+ await expectError({ ...baseline(), 'response/response.json': responseJson({ drop: 'uat-verdicts' }) }, 'required output uat-verdicts is not in fields', 'a decided run that published no verdicts');
269
+
270
+ process.stdout.write('uat.verify self-test: 4 valid branches, 36 rejected mutations\n');
@@ -0,0 +1,202 @@
1
+ // uat.verify's own law over one branch, on top of the shared step check: a person asked for the run;
2
+ // both admissions are present and taken at the pinned commit; the snapshot froze the requested cases
3
+ // in a contiguous order; every frozen case has a capture and a screenshot; the verdicts carry exactly
4
+ // the three independent lanes; the published result carries the pinned commit; cleanup deletes the run
5
+ // namespace and never a run record; the run history is append-only; and no capture, snapshot, verdict
6
+ // or published sentence contains the password. Masking is proved, not promised: the chosen placeholder
7
+ // for the shared UAT password may appear nowhere this operator writes.
8
+ import { existsSync } from 'node:fs';
9
+ import { readFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import process from 'node:process';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { validateStep } from '../../scripts/validate-step.mjs';
14
+ import { tableUnder } from '../../scripts/validate-response.mjs';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
17
+ const LANES = ['behavior', 'ux', 'ui'];
18
+ const ADMISSIONS = ['frontend-surface-audit', 'quality-verification'];
19
+ // The password placeholder this operator's self-test injects, plus any inline assignment of one. The
20
+ // shared UAT password is resolved by name at login; a run that wrote it anywhere failed its custody.
21
+ export const PASSWORD_LEAK = /uat-shared-password|password\s*[:=]\s*\S/i;
22
+ const empty = (v) => v === undefined || v === null || v === '' || v === '—';
23
+ const asList = (v) => (Array.isArray(v) ? v : v === undefined || v === null ? [] : [v]);
24
+
25
+ export async function validateUatStep(branchDir, root = ROOT) {
26
+ const base = await validateStep(root, branchDir);
27
+ const errors = [...base.errors];
28
+ const { response, request, requirements = {}, present = new Set() } = base;
29
+ if (!response || response.operatorId !== 'uat.verify') return { errors };
30
+ const has = (f) => existsSync(path.join(branchDir, f));
31
+ const read = (f) => readFile(path.join(branchDir, f), 'utf8');
32
+ const decided = response.status === 'done';
33
+
34
+ // UAT is never routine: a run exists because a person asked for it, and the run identifier and the
35
+ // exclusive lease arrive from the orchestrator rather than from a person.
36
+ if (empty(requirements.requestedBy)) errors.push('request.json: UAT runs only when a person asked; requestedBy has no value');
37
+ if (decided && empty(requirements.runId)) errors.push('request.json: the orchestrator supplies runId; a decided run cannot namespace its records without one');
38
+ if (decided && empty(requirements.lease)) errors.push('request.json: the orchestrator supplies the exclusive lease; a decided run cannot write the flow directory without one');
39
+
40
+ const pinned = (request?.contexts ?? []).find((c) => c.alias === '@workspaces/be')?.head ?? null;
41
+
42
+ let snapshot = null;
43
+ if (present.has('uat-snapshot') && has('response/data/snapshot.json')) {
44
+ try { snapshot = JSON.parse(await read('response/data/snapshot.json')); } catch { snapshot = null; }
45
+ } else if (decided) errors.push('response/data/snapshot.json: a done branch needs the frozen snapshot');
46
+ let verdicts = null;
47
+ if (present.has('uat-verdicts') && has('response/data/verdicts.json')) {
48
+ try { verdicts = JSON.parse(await read('response/data/verdicts.json')); } catch { verdicts = null; }
49
+ } else if (decided) errors.push('response/data/verdicts.json: a done branch needs the three lane verdicts');
50
+
51
+ if (snapshot) {
52
+ if (!empty(requirements.feature) && snapshot.feature !== requirements.feature) errors.push('response/data/snapshot.json: the snapshot names another feature');
53
+ if (!empty(requirements.flow) && snapshot.flow !== requirements.flow) errors.push('response/data/snapshot.json: the snapshot names another flow');
54
+ if (!empty(requirements.runId) && snapshot.runId !== requirements.runId) errors.push('response/data/snapshot.json: the snapshot names another runId');
55
+ if (!empty(requirements.requestedBy) && snapshot.requestedBy !== requirements.requestedBy) errors.push('response/data/snapshot.json: the snapshot names another requester than the person who asked');
56
+ if (snapshot.fixtureNamespace !== `uat-${snapshot.runId}`) errors.push(`response/data/snapshot.json: the fixture namespace must be uat-${snapshot.runId}, so cleanup can name exactly what this run wrote`);
57
+ if (snapshot.seed.namespace !== snapshot.fixtureNamespace) errors.push('response/data/snapshot.json: the seed namespace must equal the run fixture namespace');
58
+ if (pinned !== null && snapshot.commit !== pinned) errors.push(`response/data/snapshot.json: the snapshot froze commit ${snapshot.commit} but the request pinned @workspaces/be at ${pinned}`);
59
+ if (!snapshot.snapshotRef.startsWith(`.worktrees/uat/${snapshot.feature}/${snapshot.flow}/`)) errors.push('response/data/snapshot.json: the snapshot path must address this feature and flow');
60
+
61
+ // Admission: both receipts, both at the pinned head, both the files the request handed in.
62
+ const byKind = new Map(snapshot.admission.map((a) => [a.kind, a]));
63
+ for (const kind of ADMISSIONS) {
64
+ const entry = byKind.get(kind);
65
+ if (!entry) { errors.push(`response/data/snapshot.json: ADMISSION_MISSING — ${kind} is absent from the admission record`); continue; }
66
+ if (entry.commit !== snapshot.commit) errors.push(`response/data/snapshot.json: ADMISSION_MISSING — ${kind} was taken at ${entry.commit}, not at the pinned commit ${snapshot.commit}`);
67
+ const given = request?.inputs?.[kind];
68
+ if (given === undefined) errors.push(`request.json: ADMISSION_MISSING — input ${kind} is absent`);
69
+ else if (entry.ref !== given) errors.push(`response/data/snapshot.json: ${kind} admits ${entry.ref}, but the request handed in ${given}`);
70
+ }
71
+
72
+ // The frozen order is declared in advance, not discovered while running.
73
+ const ids = snapshot.cases.map((c) => c.caseId);
74
+ if (new Set(ids).size !== ids.length) errors.push('response/data/snapshot.json: the snapshot freezes the same case twice');
75
+ [...snapshot.cases].map((c) => c.order).sort((a, b) => a - b).forEach((order, i) => {
76
+ if (order !== i + 1) errors.push('response/data/snapshot.json: the frozen cases must carry a contiguous order starting at 1');
77
+ });
78
+ const asked = asList(requirements.cases).map(String);
79
+ if (asked.length) {
80
+ for (const c of asked) if (!ids.includes(c)) errors.push(`response/data/snapshot.json: requested case ${c} was never frozen`);
81
+ for (const c of ids) if (!asked.includes(c)) errors.push(`response/data/snapshot.json: case ${c} was frozen but the request did not ask for it`);
82
+ }
83
+
84
+ // Every frozen case leaves both kinds of evidence, and every registered artifact belongs to a case.
85
+ const captureFiles = new Set(asList(response.fields?.['uat-capture']));
86
+ const shots = new Set(asList(response.fields?.screenshot));
87
+ if (decided) {
88
+ for (const c of snapshot.cases) {
89
+ const capture = `response/data/captures/${c.caseId}.json`;
90
+ const shot = `response/artifacts/${c.caseId}.png`;
91
+ if (!captureFiles.has(capture)) errors.push(`response/response.json: case ${c.caseId} has no capture registered at ${capture}`);
92
+ if (!shots.has(shot)) errors.push(`response/response.json: case ${c.caseId} has no screenshot registered at ${shot}`);
93
+ }
94
+ for (const f of captureFiles) if (!ids.some((id) => f === `response/data/captures/${id}.json`)) errors.push(`response/response.json: the capture ${f} belongs to no frozen case`);
95
+ for (const f of shots) if (!ids.some((id) => f === `response/artifacts/${id}.png`)) errors.push(`response/response.json: the screenshot ${f} belongs to no frozen case`);
96
+ }
97
+
98
+ const frozenById = new Map(snapshot.cases.map((c) => [c.caseId, c]));
99
+ for (const f of captureFiles) {
100
+ if (!has(f)) continue;
101
+ let capture; try { capture = JSON.parse(await read(f)); } catch { continue; }
102
+ const frozen = frozenById.get(capture.caseId);
103
+ if (!frozen) { errors.push(`${f}: case ${capture.caseId} was captured without being frozen into the snapshot`); continue; }
104
+ if (capture.order !== frozen.order) errors.push(`${f}: case ${capture.caseId} was executed out of its frozen order`);
105
+ if (!empty(snapshot.runId) && capture.runId !== snapshot.runId) errors.push(`${f}: the capture belongs to run ${capture.runId}, not ${snapshot.runId}`);
106
+ if (capture.screenshotRef !== `response/artifacts/${capture.caseId}.png`) errors.push(`${f}: the capture must point at its own case screenshot`);
107
+ if (Date.parse(capture.executedAt) <= Date.parse(snapshot.frozenAt)) errors.push(`${f}: case ${capture.caseId} executed at or before the snapshot freeze`);
108
+ const seen = new Set(capture.assertions.map((a) => a.assertionId));
109
+ for (const a of frozen.assertions) if (!seen.has(a)) errors.push(`${f}: the frozen assertion ${a} was never observed`);
110
+ for (const a of capture.assertions) if (!frozen.assertions.includes(a.assertionId)) errors.push(`${f}: the assertion ${a.assertionId} was never frozen`);
111
+ }
112
+ }
113
+
114
+ if (verdicts) {
115
+ const names = verdicts.lanes.map((l) => l.lane);
116
+ for (const lane of LANES) if (!names.includes(lane)) errors.push(`response/data/verdicts.json: the ${lane} lane is missing; the three lanes are judged apart and all three are published`);
117
+ for (const name of names) if (!LANES.includes(name)) errors.push(`response/data/verdicts.json: ${name} is not one of the three lanes`);
118
+ if (new Set(names).size !== names.length) errors.push('response/data/verdicts.json: a lane may report at most one verdict');
119
+ if (snapshot) {
120
+ if (verdicts.runId !== snapshot.runId) errors.push('response/data/verdicts.json: the result belongs to another run than the snapshot');
121
+ if (verdicts.commit !== snapshot.commit) errors.push(`response/data/verdicts.json: the result carries commit ${verdicts.commit} but the run was pinned at ${snapshot.commit}`);
122
+ if (verdicts.resultRef !== `${snapshot.snapshotRef.replace(/snapshot\.json$/, '')}runs/${snapshot.runId}/result.json`) errors.push('response/data/verdicts.json: the result must be appended under runs/<runId>/ of this flow directory');
123
+ if (verdicts.latestRef !== `${snapshot.snapshotRef.replace(/snapshot\.json$/, '')}latest`) errors.push('response/data/verdicts.json: latest must be the pointer of this flow directory');
124
+ if (verdicts.cleanup.namespace !== snapshot.fixtureNamespace) errors.push('response/data/verdicts.json: cleanup must name the exact run fixture namespace and nothing wider');
125
+ }
126
+ if (pinned !== null && verdicts.commit !== pinned) errors.push(`response/data/verdicts.json: the result commit ${verdicts.commit} is not the pinned head ${pinned}`);
127
+ if (decided && !verdicts.cleanup.performed) errors.push('response/data/verdicts.json: a decided run cleans its own namespace before it publishes');
128
+
129
+ const failing = verdicts.lanes.filter((l) => l.verdict === 'fail').map((l) => l.lane);
130
+ const next = new Set(response.next ?? []);
131
+ if (decided && !failing.length && !next.has('git.publish')) errors.push('response/response.json: all three lanes pass, so the run hands to git.publish');
132
+ if (decided && failing.includes('behavior') && !next.has('backend.source.apply')) errors.push('response/response.json: the behaviour lane failed, so the run hands to backend.source.apply');
133
+ if (decided && failing.includes('ux') && !next.has('user')) errors.push('response/response.json: a UX failure is a question of intent; it hands to a person, and the flow is verified again only after that decision');
134
+ if (decided && failing.length && next.has('git.publish')) errors.push('response/response.json: a failing lane cannot hand to git.publish');
135
+ }
136
+
137
+ if (present.has('uat-flow-verification') && has('response/response.md')) {
138
+ const text = await read('response/response.md');
139
+ const snap = Object.fromEntries((tableUnder(text, '## Snapshot') ?? []).map(([k, v]) => [k, v]));
140
+ if (snapshot) {
141
+ if (snap.Run !== snapshot.runId) errors.push(`response/response.md: Snapshot names run ${snap.Run}, the frozen snapshot names ${snapshot.runId}`);
142
+ if (snap.Feature !== snapshot.feature) errors.push('response/response.md: Snapshot names another feature');
143
+ if (snap.Flow !== snapshot.flow) errors.push('response/response.md: Snapshot names another flow');
144
+ if (snap.Commit !== snapshot.commit) errors.push('response/response.md: Snapshot names another commit than the frozen one');
145
+ if (snap.Namespace !== snapshot.fixtureNamespace) errors.push('response/response.md: Snapshot names another fixture namespace');
146
+ if (snap['Requested by'] !== snapshot.requestedBy) errors.push('response/response.md: Snapshot names another requester');
147
+ const admission = tableUnder(text, '## Admission') ?? [];
148
+ for (const [kind, ref, commit] of admission) {
149
+ const entry = snapshot.admission.find((a) => a.kind === kind);
150
+ if (!entry) { errors.push(`response/response.md: Admission names ${kind}, which the snapshot did not record`); continue; }
151
+ if (entry.ref !== ref || entry.commit !== commit) errors.push(`response/response.md: the ${kind} admission differs from the frozen record`);
152
+ }
153
+ const cases = tableUnder(text, '## Cases') ?? [];
154
+ if (cases.length !== snapshot.cases.length) errors.push(`response/response.md: Cases has ${cases.length} rows, the snapshot froze ${snapshot.cases.length}`);
155
+ }
156
+ if (verdicts) {
157
+ const lanes = tableUnder(text, '## Lanes') ?? [];
158
+ for (const [lane, verdict] of lanes) {
159
+ const entry = verdicts.lanes.find((l) => l.lane === lane);
160
+ if (!entry) { errors.push(`response/response.md: Lanes names ${lane}, which the verdicts do not`); continue; }
161
+ if (entry.verdict !== verdict) errors.push(`response/response.md: the ${lane} lane reads ${verdict} but the verdicts say ${entry.verdict}`);
162
+ }
163
+ }
164
+ } else if (decided) errors.push('response/response.md: a done branch needs the verification receipt');
165
+
166
+ // Custody is proved on the written bytes, not asserted in prose.
167
+ const scanned = ['response/response.md', 'response/data/snapshot.json', 'response/data/verdicts.json', ...asList(response.fields?.['uat-capture'])];
168
+ for (const f of scanned) {
169
+ if (!has(f)) continue;
170
+ if (PASSWORD_LEAK.test(await read(f))) errors.push(`${f}: the shared UAT password appears in a file this operator writes; the credential is resolved by name at login and is never recorded`);
171
+ }
172
+
173
+ // The run history is append-only: a run folder that exists already is never rewritten.
174
+ if (snapshot && verdicts && existsSync(snapshot.flowRoot)) {
175
+ const runDir = path.join(snapshot.flowRoot, 'runs', snapshot.runId);
176
+ const resultFile = path.join(runDir, 'result.json');
177
+ if (existsSync(resultFile)) {
178
+ let recorded = null;
179
+ try { recorded = JSON.parse(await readFile(resultFile, 'utf8')); } catch { recorded = null; }
180
+ if (recorded === null) errors.push(`${snapshot.snapshotRef.replace(/snapshot\.json$/, '')}runs/${snapshot.runId}/result.json: the existing run record cannot be read, and a run record is never overwritten`);
181
+ else {
182
+ const same = recorded.runId === verdicts.runId && recorded.commit === verdicts.commit
183
+ && JSON.stringify(recorded.lanes) === JSON.stringify(verdicts.lanes);
184
+ if (!same) errors.push(`runs/${snapshot.runId}: a run record already exists with a different result; runs are append-only, so a second attempt is a new runId`);
185
+ }
186
+ } else if (decided) errors.push(`runs/${snapshot.runId}: a decided run appends its record under runs/<runId>/ before it emits`);
187
+ const latestFile = path.join(snapshot.flowRoot, 'latest');
188
+ if (decided && existsSync(latestFile)) {
189
+ const latest = (await readFile(latestFile, 'utf8')).trim();
190
+ if (latest !== snapshot.runId) errors.push(`latest: points at ${latest}, but this run published ${snapshot.runId}`);
191
+ }
192
+ }
193
+
194
+ return { errors };
195
+ }
196
+
197
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
198
+ const target = process.argv[2];
199
+ if (!target) { process.stderr.write('usage: node validate.mjs <session>/step-N/parallel-M\n'); process.exit(2); }
200
+ const { errors } = await validateUatStep(path.resolve(target));
201
+ if (errors.length) { process.stderr.write(`${errors.join('\n')}\n`); process.exitCode = 1; } else process.stdout.write('valid uat.verify branch\n');
202
+ }
@@ -0,0 +1,90 @@
1
+ {
2
+ "schemaVersion": 9,
3
+ "note": "Stop codes only workspace.bind emits. Same entry shape as operators/errors.json, scope implicit. Shared codes (INVALID_INPUT, SOURCE_DRIFT, NO_PROGRESS) come from operators/errors.json, and BRANCH_POLICY_VIOLATION is not defined here because git.publish emits it too: a code two operators emit belongs in operators/errors.json with both ids in scope. Every domain below is the one the old package's OWNING_DOMAIN table published, and routing.json answers each of them for this operator: workspace resumes, source resumes, identity and runtime are external, caller is a person.",
4
+ "codes": {
5
+ "IDENTITY_UNVERIFIED": {
6
+ "domain": "identity",
7
+ "disposition": "terminate",
8
+ "meaning": {
9
+ "en": "The machine identity or its encrypted credential roster is missing or stale.",
10
+ "vi": "Định danh máy hoặc roster credential đã mã hoá của nó thiếu hoặc cũ."
11
+ },
12
+ "resume": {
13
+ "en": "Verify the machine identity and seal its roster.",
14
+ "vi": "Xác minh định danh máy và niêm phong roster của nó."
15
+ }
16
+ },
17
+ "ROUTE_UNDECLARED": {
18
+ "domain": "workspace",
19
+ "disposition": "terminate",
20
+ "meaning": {
21
+ "en": "No portable declaration names this project and role.",
22
+ "vi": "Không khai báo portable nào gọi tên project và role này."
23
+ },
24
+ "resume": {
25
+ "en": "Declare the route; this operator never repairs one.",
26
+ "vi": "Khai báo route; operator này không bao giờ tự sửa."
27
+ }
28
+ },
29
+ "ROUTE_UNHYDRATED": {
30
+ "domain": "workspace",
31
+ "disposition": "terminate",
32
+ "meaning": {
33
+ "en": "The declaration exists but no local route projects it onto this machine.",
34
+ "vi": "Khai báo có tồn tại nhưng không route local nào chiếu nó xuống máy này."
35
+ },
36
+ "resume": {
37
+ "en": "Hydrate the route on this machine.",
38
+ "vi": "Hydrate route trên máy này."
39
+ }
40
+ },
41
+ "ROUTE_MISMATCH": {
42
+ "domain": "workspace",
43
+ "disposition": "terminate",
44
+ "meaning": {
45
+ "en": "The hydrated route disagrees with the closed portable route, or belongs to another Source.",
46
+ "vi": "Route đã hydrate mâu thuẫn với route portable đóng, hoặc thuộc về một Source khác."
47
+ },
48
+ "resume": {
49
+ "en": "Correct the hydration.",
50
+ "vi": "Sửa lại phần hydrate."
51
+ }
52
+ },
53
+ "CHECKOUT_DIRTY": {
54
+ "domain": "source",
55
+ "disposition": "terminate",
56
+ "meaning": {
57
+ "en": "Something is dirty outside the declared write roots.",
58
+ "vi": "Có thứ đang bẩn ngoài các write root đã khai báo."
59
+ },
60
+ "resume": {
61
+ "en": "Clean the boundary, or declare the write roots that cover it; this operator never stashes.",
62
+ "vi": "Dọn sạch ranh giới, hoặc khai báo write root bao được nó; operator này không bao giờ stash."
63
+ }
64
+ },
65
+ "ENDPOINT_AUTHORITY_STALE": {
66
+ "domain": "runtime",
67
+ "disposition": "terminate",
68
+ "meaning": {
69
+ "en": "The endpoint binding is not the closed port projection, or its fingerprint is stale.",
70
+ "vi": "Ràng buộc endpoint không phải phép chiếu port đóng, hoặc fingerprint của nó đã cũ."
71
+ },
72
+ "resume": {
73
+ "en": "Recompute the authority fingerprint at its owner.",
74
+ "vi": "Tính lại fingerprint thẩm quyền ở phía chủ của nó."
75
+ }
76
+ },
77
+ "RUNTIME_NOT_READY": {
78
+ "domain": "runtime",
79
+ "disposition": "terminate",
80
+ "meaning": {
81
+ "en": "The runtime owner registry is missing, stale, or not ready while the caller must consume it.",
82
+ "vi": "Registry chủ runtime thiếu, cũ hoặc chưa sẵn sàng trong khi người gọi phải tiêu thụ nó."
83
+ },
84
+ "resume": {
85
+ "en": "Raise one coordination request to the registered owner and wait for a ready generation.",
86
+ "vi": "Nêu một yêu cầu phối hợp tới chủ đã đăng ký và chờ một generation sẵn sàng."
87
+ }
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "schemaVersion": 9,
3
+ "id": "workspace.bind",
4
+ "domain": "workspace",
5
+ "job": "Resolve one project and role into a verified checkout identity, its exact source head, and the closed runtime binding it may consume, and return that as one typed route receipt.",
6
+ "package": "operator.md",
7
+ "errors": "errors.json",
8
+ "validator": "validate.mjs",
9
+ "selfTest": "self-test.mjs",
10
+ "resources": {
11
+ "profile": "luna",
12
+ "grammarBound": false,
13
+ "tools": {
14
+ "@tools/fileread": "context-aliases",
15
+ "@tools/git": "read",
16
+ "@tools/shell": "declared-commands",
17
+ "@tools/http": "probe",
18
+ "@tools/secrets": "resolve-by-name"
19
+ }
20
+ }
21
+ }