@starci/skills 1.1.0 → 1.2.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 (75) hide show
  1. package/INDEX.md +3 -1
  2. package/INDEX.vi.md +3 -1
  3. package/README.md +6 -1
  4. package/README.vi.md +6 -1
  5. package/knowledge/ui/presentation/INDEX.md +5 -0
  6. package/knowledge/ui/presentation/INDEX.vi.md +1 -0
  7. package/knowledge/ui/presentation/radius.md +183 -0
  8. package/knowledge/ui/presentation/radius.vi.md +182 -0
  9. package/operators/INDEX.md +5 -5
  10. package/operators/INDEX.vi.md +5 -5
  11. package/operators/architecture-decide/operator.md +27 -4
  12. package/operators/architecture-decide/operator.vi.md +23 -4
  13. package/operators/architecture-decide/self-test.mjs +18 -1
  14. package/operators/architecture-decide/validate.mjs +29 -1
  15. package/operators/backend-source-apply/operator.md +197 -182
  16. package/operators/backend-source-apply/operator.vi.md +190 -178
  17. package/operators/backend-source-apply/self-test.mjs +257 -257
  18. package/operators/backend-source-apply/validate.mjs +240 -240
  19. package/operators/business-decide/self-test.mjs +2 -1
  20. package/operators/business-decide/validate.mjs +3 -0
  21. package/operators/content-generate/self-test.mjs +1 -1
  22. package/operators/frontend-direction-decide/self-test.mjs +1 -1
  23. package/operators/frontend-presentation-resolve/operator.md +6 -0
  24. package/operators/frontend-presentation-resolve/operator.vi.md +6 -0
  25. package/operators/frontend-presentation-resolve/self-test.mjs +6 -5
  26. package/operators/frontend-presentation-resolve/validate.mjs +30 -6
  27. package/operators/frontend-source-apply/operator.md +3 -1
  28. package/operators/frontend-source-apply/operator.vi.md +3 -1
  29. package/operators/frontend-source-apply/self-test.mjs +1 -1
  30. package/operators/frontend-surface-audit/self-test.mjs +1 -1
  31. package/operators/git-publish/self-test.mjs +1 -1
  32. package/operators/platform-operate/self-test.mjs +1 -1
  33. package/operators/quality-verify/operator.md +5 -1
  34. package/operators/quality-verify/operator.vi.md +5 -1
  35. package/operators/quality-verify/self-test.mjs +15 -2
  36. package/operators/quality-verify/validate.mjs +21 -1
  37. package/operators/release-deploy/self-test.mjs +235 -235
  38. package/operators/uat-verify/operator.md +20 -6
  39. package/operators/uat-verify/operator.vi.md +21 -7
  40. package/operators/uat-verify/self-test.mjs +6 -5
  41. package/operators/uat-verify/validate.mjs +10 -4
  42. package/operators/workspace-bind/operator.md +18 -4
  43. package/operators/workspace-bind/operator.vi.md +16 -4
  44. package/operators/workspace-bind/self-test.mjs +3 -1
  45. package/operators/workspace-bind/validate.mjs +1 -0
  46. package/package.json +1 -1
  47. package/readiness/initialization/workspaces/local-route.schema.json +1 -1
  48. package/resources/orchestrator.json +14 -8
  49. package/routing.json +2 -1
  50. package/scripts/validate-request.mjs +12 -1
  51. package/scripts/validate-response.mjs +14 -3
  52. package/scripts/validate-step.mjs +2 -1
  53. package/scripts/validate-workflows.mjs +8 -1
  54. package/templates/kinds/architecture-decision.contract.json +1 -0
  55. package/templates/kinds/architecture-decision.skeleton.md +6 -0
  56. package/templates/kinds/backend-source-application.contract.json +1 -1
  57. package/templates/kinds/backend-source-application.skeleton.md +1 -1
  58. package/templates/kinds/frontend-presentation-resolution.contract.json +1 -1
  59. package/templates/kinds/frontend-presentation-resolution.skeleton.md +1 -0
  60. package/templates/kinds/mutations.schema.json +65 -65
  61. package/templates/kinds/route.schema.json +4 -2
  62. package/templates/kinds/stack-model.schema.json +23 -1
  63. package/templates/kinds/workspace-route-binding.contract.json +2 -2
  64. package/templates/step/response.schema.json +10 -0
  65. package/templates/step/state.schema.json +205 -0
  66. package/workflows/README.md +3 -0
  67. package/workflows/README.vi.md +3 -0
  68. package/workflows/backend-feature.json +26 -5
  69. package/workflows/content-unit.json +4 -1
  70. package/workflows/frontend-new-surface.json +15 -5
  71. package/workflows/frontend-reconstruct.json +10 -4
  72. package/workflows/frontend-refine.json +10 -4
  73. package/workflows/frontend-with-uat.json +15 -3
  74. package/workflows/full-feature.json +31 -8
  75. package/workflows/release.json +7 -1
@@ -1,240 +1,240 @@
1
- // backend.source.apply's own law over one branch, on top of the shared step check: the write set arrives
2
- // as exactly one commit on the session branch, and the mutation record, response.json.commits and the
3
- // change record name that same sha; every operation the frozen contract carries is restated once and
4
- // applied; every change lies inside the mutable ceiling, names a declared operation, appears once, and
5
- // carries the hash pair its kind demands; a dry run commits nothing, writes no after hash, measures no
6
- // facet and runs no proof, and reports every planned path as unchanged in the change record; every
7
- // declared facet of an applied run has its own conformance file, named for
8
- // the operation and facet it measures, with evidence, and every verdict in a done branch conforms;
9
- // every declared proof ran exactly once in its own file, with a command, an exit code that agrees with
10
- // its result, and a pass; the change record and the receipt describe the same files; no finding names
11
- // an undeclared operation, and a done branch raises no business question.
12
- import { existsSync } from 'node:fs';
13
- import { readFile } from 'node:fs/promises';
14
- import path from 'node:path';
15
- import process from 'node:process';
16
- import { fileURLToPath } from 'node:url';
17
- import { validateStep } from '../../scripts/validate-step.mjs';
18
- import { tableUnder } from '../../scripts/validate-response.mjs';
19
-
20
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
21
- // A change record is the only durable trace that a file was mutated, so the hash pair has to agree with
22
- // the kind. A modified record whose before and after hashes are equal describes a mutation that never
23
- // happened.
24
- const HASH_SHAPE = { added: { before: 'null', after: 'set' }, modified: { before: 'set', after: 'set' }, deleted: { before: 'set', after: 'null' } };
25
- const empty = (v) => v === undefined || v === null || v === '' || v === '—';
26
- const fields = (rows) => Object.fromEntries((rows ?? []).map(([k, v]) => [k, v]));
27
- const listed = (value) => (value === undefined ? [] : Array.isArray(value) ? value : [value]);
28
-
29
- export async function validateBackendStep(branchDir, root = ROOT) {
30
- const base = await validateStep(root, branchDir);
31
- const errors = [...base.errors];
32
- const { response, requirements = {}, present = new Set() } = base;
33
- if (!response || response.operatorId !== 'backend.source.apply') return { errors };
34
- const has = (f) => existsSync(path.join(branchDir, f));
35
- const read = (f) => readFile(path.join(branchDir, f), 'utf8');
36
- const readJson = async (f) => { try { return JSON.parse(await read(f)); } catch { return null; } };
37
- const mutable = new Set(Array.isArray(requirements.mutableFileRefs) ? requirements.mutableFileRefs : []);
38
- // `mode` decides whether this branch touched the checkout at all; everything below reads differently under dry.
39
- const mode = requirements.mode ?? 'apply';
40
-
41
- // A blocked branch has no implementation at all, and nothing it may have written is committed.
42
- if (response.status === 'blocked' && (present.has('backend-source-application') || present.has('mutations') || present.has('conformance') || present.has('proof'))) {
43
- errors.push('response/response.json: a blocked branch cannot carry an implementation');
44
- }
45
- if (response.status === 'blocked' && (response.commits ?? []).length) {
46
- errors.push('response/response.json: a blocked branch commits nothing');
47
- }
48
-
49
- const mutations = present.has('mutations') && has('response/data/mutations.json') ? await readJson('response/data/mutations.json') : null;
50
- const declared = mutations ? mutations.operations : [];
51
- const declaredById = new Map(declared.map((o) => [o.operationId, o]));
52
-
53
- // The plan cannot re-decide the mode the request asked for.
54
- if (mutations && mutations.mode !== mode) errors.push(`response/data/mutations.json: mode ${mutations.mode} differs from the request's ${mode}`);
55
-
56
- // A dry run answers with the plan alone: nothing committed, nothing measured, nothing hashed after.
57
- if (mode === 'dry') {
58
- if (mutations && mutations.commit !== null) errors.push('response/data/mutations.json: a dry run commits nothing, so commit must be null');
59
- if ((response.commits ?? []).length) errors.push('response/response.json: a dry run records no commit');
60
- if (present.has('conformance') || present.has('proof')) errors.push('response/response.json: a dry run measures nothing, so it carries no conformance or proof record');
61
- }
62
-
63
- // One commit on the session branch, named the same way in three places.
64
- if (response.status === 'done' && mode === 'apply') {
65
- const commits = response.commits ?? [];
66
- if (commits.length !== 1) errors.push(`response/response.json: a done branch commits its whole write set once, found ${commits.length} commits`);
67
- if (mutations && commits.length === 1 && mutations.commit !== commits[0]) errors.push(`response/data/mutations.json: commit ${mutations.commit} differs from response.json commits[0] ${commits[0]}`);
68
- // Required of an applied branch only: a dry run has no source to measure, and the Outputs table can only say yes or no.
69
- for (const kind of ['conformance', 'proof']) if (!present.has(kind)) errors.push(`response/response.json: required output ${kind} is not in fields`);
70
- }
71
- if (mutations) {
72
- if (mode === 'apply' && mutations.commit === mutations.base) errors.push('response/data/mutations.json: the commit equals the base, so nothing was written on the session branch');
73
- for (const operation of declared) {
74
- const at = `response/data/mutations.json: operation ${operation.operationId}`;
75
- if (mutable.size && !mutable.has(operation.writerRef)) errors.push(`${at} names writer ${operation.writerRef} outside the mutable ceiling`);
76
- const shipsMigration = (operation.migrationRefs ?? []).length > 0;
77
- // A migration without a replay proof is a schema change nobody re-ran.
78
- if (shipsMigration && !operation.proofKinds.includes('migration-replay')) errors.push(`${at} ships a migration without declaring the migration-replay proof`);
79
- if (shipsMigration && !operation.facets.includes('migration')) errors.push(`${at} ships a migration without declaring the migration facet`);
80
- if (!shipsMigration && operation.proofKinds.includes('migration-replay')) errors.push(`${at} declares a migration-replay proof but ships no migration`);
81
- // A mutation arriving through a boundary declared not to mutate.
82
- if (operation.transactionBoundary === 'read-only' && shipsMigration) errors.push(`${at} is read-only but ships a migration`);
83
- // A redelivered event applies twice unless something makes the write idempotent.
84
- if (operation.transport === 'event-consumer' && operation.idempotencyKind === 'none') errors.push(`${at} consumes events with no idempotency and will apply twice on redelivery`);
85
- if (new Set(operation.facets).size !== operation.facets.length) errors.push(`${at} repeats a contract facet`);
86
- if (new Set(operation.proofKinds).size !== operation.proofKinds.length) errors.push(`${at} repeats a proof kind`);
87
- if (new Set(operation.authorityDecisionIds).size !== operation.authorityDecisionIds.length) errors.push(`${at} repeats a business decision identifier`);
88
- }
89
- if (declared.length && declaredById.size !== declared.length) errors.push('response/data/mutations.json: the contract repeats an operationId');
90
-
91
- const changedPaths = new Set();
92
- for (const change of mutations.changes) {
93
- if (changedPaths.has(change.path)) errors.push(`response/data/mutations.json: file ${change.path} carries more than one change record`);
94
- changedPaths.add(change.path);
95
- if (declaredById.size && !declaredById.has(change.operationId)) errors.push(`response/data/mutations.json: change on ${change.path} names undeclared operation ${change.operationId}`);
96
- if (mutable.size && !mutable.has(change.path)) errors.push(`response/data/mutations.json: change on ${change.path} lies outside the mutable ceiling`);
97
- const shape = HASH_SHAPE[change.change];
98
- if ((shape.before === 'set') !== (change.beforeHash !== null)) errors.push(`response/data/mutations.json: change on ${change.path} is ${change.change} with the wrong before hash`);
99
- if (mode === 'dry') {
100
- // A planned path has no new content yet, so an after hash could only have been invented.
101
- if (change.afterHash !== null) errors.push(`response/data/mutations.json: change on ${change.path} reports an after hash under a dry run, which writes nothing`);
102
- } else {
103
- if ((shape.after === 'set') !== (change.afterHash !== null)) errors.push(`response/data/mutations.json: change on ${change.path} is ${change.change} with the wrong after hash`);
104
- if (change.change === 'modified' && change.beforeHash === change.afterHash) errors.push(`response/data/mutations.json: change on ${change.path} records a modification whose hashes are identical`);
105
- }
106
- }
107
- }
108
-
109
- // One conformance file per declared facet, named for what it measures.
110
- const conformanceKeys = new Set();
111
- for (const file of listed(response.fields?.conformance)) {
112
- const record = has(file) ? await readJson(file) : null;
113
- if (!record) continue;
114
- const expected = `response/data/conformance/${record.operationId}.${record.facet}.json`;
115
- if (file !== expected) errors.push(`${file}: a conformance record of ${record.operationId} ${record.facet} must be filed as ${expected}`);
116
- const key = `${record.operationId}|${record.facet}`;
117
- if (conformanceKeys.has(key)) errors.push(`${file}: operation ${record.operationId} records ${record.facet} conformance twice`);
118
- conformanceKeys.add(key);
119
- const operation = declaredById.get(record.operationId);
120
- if (declaredById.size && !operation) { errors.push(`${file}: conformance names undeclared operation ${record.operationId}`); continue; }
121
- if (operation && !operation.facets.includes(record.facet)) errors.push(`${file}: operation ${record.operationId} proves undeclared facet ${record.facet}`);
122
- if (mutations && record.contractFingerprint !== mutations.contractFingerprint) errors.push(`${file}: measured against another contract fingerprint than the one filled`);
123
- // A widened or narrowed facet means the frozen contract was not filled as written.
124
- if (response.status === 'done' && record.verdict !== 'conforms') errors.push(`${file}: operation ${record.operationId} reports ${record.verdict} ${record.facet} conformance in a done branch`);
125
- if (empty(record.evidenceRef)) errors.push(`${file}: operation ${record.operationId} asserts ${record.facet} conformance with no evidence`);
126
- }
127
-
128
- const proofKeys = new Set();
129
- for (const file of listed(response.fields?.proof)) {
130
- const record = has(file) ? await readJson(file) : null;
131
- if (!record) continue;
132
- const expected = `response/data/proofs/${record.operationId}.${record.proofKind}.json`;
133
- if (file !== expected) errors.push(`${file}: a proof of ${record.operationId} ${record.proofKind} must be filed as ${expected}`);
134
- const key = `${record.operationId}|${record.proofKind}`;
135
- if (proofKeys.has(key)) errors.push(`${file}: operation ${record.operationId} records the ${record.proofKind} proof twice`);
136
- proofKeys.add(key);
137
- const operation = declaredById.get(record.operationId);
138
- if (declaredById.size && !operation) { errors.push(`${file}: proof names undeclared operation ${record.operationId}`); continue; }
139
- if (operation && !operation.proofKinds.includes(record.proofKind)) errors.push(`${file}: operation ${record.operationId} runs undeclared proof ${record.proofKind}`);
140
- if (mutations && record.contractFingerprint !== mutations.contractFingerprint) errors.push(`${file}: run against another contract fingerprint than the one filled`);
141
- // The exit code is what was actually read; the verdict is a reading of it.
142
- if ((record.exitCode === 0) !== (record.result === 'passed')) errors.push(`${file}: exit code ${record.exitCode} contradicts the ${record.result} verdict`);
143
- if (response.status === 'done' && record.result !== 'passed') errors.push(`${file}: operation ${record.operationId} reports a failed ${record.proofKind} proof in a done branch`);
144
- }
145
-
146
- if (response.status === 'done' && mode === 'apply') {
147
- for (const operation of declared) {
148
- // Silence about a facet reads exactly like a pass.
149
- for (const facet of operation.facets) if (!conformanceKeys.has(`${operation.operationId}|${facet}`)) errors.push(`response/data/conformance/${operation.operationId}.${facet}.json: the operation declares the ${facet} facet and no record proves it`);
150
- for (const proofKind of operation.proofKinds) if (!proofKeys.has(`${operation.operationId}|${proofKind}`)) errors.push(`response/data/proofs/${operation.operationId}.${proofKind}.json: the operation declares the ${proofKind} proof and never ran it`);
151
- }
152
- }
153
-
154
- let receiptFiles = null;
155
- if (present.has('backend-source-application') && has('response/response.md')) {
156
- const text = await read('response/response.md');
157
- const binding = fields(tableUnder(text, '## Binding'));
158
- if (!empty(requirements.outcome) && binding.Outcome !== requirements.outcome) errors.push('response/response.md: Outcome differs from the request');
159
- if (!empty(requirements.featureId) && binding.Feature !== requirements.featureId) errors.push('response/response.md: Feature differs from the request');
160
- if (binding.Mode !== undefined && binding.Mode !== mode) errors.push(`response/response.md: Mode ${binding.Mode} differs from the request's ${mode}`);
161
- if (mutations) {
162
- if (binding['Contract fingerprint'] !== mutations.contractFingerprint) errors.push('response/response.md: Contract fingerprint differs from the fingerprint the mutations were measured against');
163
- if (binding.Base !== mutations.base) errors.push('response/response.md: Base differs from the base the session branch was cut from');
164
- if (binding.Branch !== mutations.branch) errors.push('response/response.md: Branch differs from the session branch the write set lives on');
165
- if (binding.Commit !== (mutations.commit ?? '—')) errors.push('response/response.md: Commit differs from the one commit the write set arrived as');
166
- }
167
-
168
- const applied = new Set();
169
- for (const [operationId, transport, writer, transaction, idempotency, decisions] of tableUnder(text, '## Operations') ?? []) {
170
- if (applied.has(operationId)) errors.push(`response/response.md: operation ${operationId} is declared more than once`);
171
- applied.add(operationId);
172
- const operation = declaredById.get(operationId);
173
- if (declaredById.size && !operation) { errors.push(`response/response.md: operation ${operationId} is not in the frozen contract`); continue; }
174
- if (!operation) continue;
175
- if (transport !== operation.transport) errors.push(`response/response.md: operation ${operationId} reports transport ${transport}, the contract froze ${operation.transport}`);
176
- if (writer !== operation.writerRef) errors.push(`response/response.md: operation ${operationId} reports writer ${writer}, the contract froze ${operation.writerRef}`);
177
- if (transaction !== operation.transactionBoundary) errors.push(`response/response.md: operation ${operationId} reports transaction ${transaction}, the contract froze ${operation.transactionBoundary}`);
178
- if (idempotency !== operation.idempotencyKind) errors.push(`response/response.md: operation ${operationId} reports idempotency ${idempotency}, the contract froze ${operation.idempotencyKind}`);
179
- const cited = String(decisions).split(',').map((s) => s.trim()).filter(Boolean);
180
- for (const decisionId of cited) if (!operation.authorityDecisionIds.includes(decisionId)) errors.push(`response/response.md: operation ${operationId} cites decision ${decisionId}, which the contract does not bind`);
181
- for (const decisionId of operation.authorityDecisionIds) if (!cited.includes(decisionId)) errors.push(`response/response.md: operation ${operationId} does not restate approved decision ${decisionId}`);
182
- }
183
- if (response.status === 'done') for (const operationId of declaredById.keys()) if (!applied.has(operationId)) errors.push(`response/response.md: operation ${operationId} was declared but never applied`);
184
-
185
- const seenFiles = new Set();
186
- const changeById = new Map((mutations?.changes ?? []).map((c) => [c.path, c]));
187
- for (const [file, changeKind, operationId, before, after] of tableUnder(text, '## Changes') ?? []) {
188
- if (seenFiles.has(file)) errors.push(`response/response.md: file ${file} carries more than one change record`);
189
- seenFiles.add(file);
190
- const record = changeById.get(file);
191
- if (mutations && !record) { errors.push(`response/response.md: change on ${file} is absent from the mutation record`); continue; }
192
- if (!record) continue;
193
- if (changeKind !== record.change) errors.push(`response/response.md: change on ${file} is ${changeKind} here and ${record.change} in the mutation record`);
194
- if (operationId !== record.operationId) errors.push(`response/response.md: change on ${file} names operation ${operationId}, the mutation record names ${record.operationId}`);
195
- if ((empty(before) ? null : before) !== record.beforeHash) errors.push(`response/response.md: change on ${file} disagrees with the mutation record on the before hash`);
196
- if ((empty(after) ? null : after) !== record.afterHash) errors.push(`response/response.md: change on ${file} disagrees with the mutation record on the after hash`);
197
- }
198
- if (mutations) for (const p of changeById.keys()) if (!seenFiles.has(p)) errors.push(`response/response.md: the mutation record changed ${p}, which the receipt omits`);
199
- receiptFiles = seenFiles;
200
-
201
- for (const [code, operationId] of tableUnder(text, '## Findings') ?? []) {
202
- if (!empty(operationId) && declaredById.size && !declaredById.has(operationId)) errors.push(`response/response.md: finding ${code} names undeclared operation ${operationId}`);
203
- // Raising the business question and shipping anyway is the exact contradiction to catch.
204
- if (code === 'BUSINESS_QUESTION_RAISED' && response.status === 'done') errors.push('response/response.md: a done branch cannot raise an unresolved business question');
205
- }
206
- }
207
-
208
- // The change record the next step reads must describe the same files and pin the same commit.
209
- if (present.has('changes') && has('response/changes.md')) {
210
- const text = await read('response/changes.md');
211
- if (mutations) {
212
- const checkout = fields(tableUnder(text, '## Binding')).Checkout ?? '';
213
- // A dry run wrote nothing, so the next request can only pin the base it read.
214
- const expected = mode === 'dry'
215
- ? `@workspaces/be at ${mutations.base} on ${mutations.branch}, nothing written`
216
- : `@workspaces/be at ${mutations.base} → ${mutations.commit} on ${mutations.branch}`;
217
- const seen = checkout.replaceAll('`', '').replace(/\s+/g, ' ').trim();
218
- if (seen !== expected) errors.push(`response/changes.md: Checkout must read ${expected}, so the next request can pin exactly what was written; it reads ${seen}`);
219
- }
220
- if (mode === 'dry') {
221
- // The Change column reports the working tree, and a dry run left every path in it alone.
222
- for (const [file, kind] of tableUnder(text, '## Files') ?? []) {
223
- if (kind !== 'unchanged') errors.push(`response/changes.md: ${file} is reported ${kind} under a dry run, which leaves every path unchanged in the working tree`);
224
- }
225
- }
226
- if (receiptFiles) {
227
- const changed = new Set((tableUnder(text, '## Files') ?? []).map(([p]) => p));
228
- for (const file of changed) if (!receiptFiles.has(file)) errors.push(`response/changes.md: ${file} is not in the receipt's change records`);
229
- for (const file of receiptFiles) if (!changed.has(file)) errors.push(`response/changes.md: the receipt changed ${file}, which the change record omits`);
230
- }
231
- }
232
- return { errors };
233
- }
234
-
235
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
236
- const target = process.argv[2];
237
- if (!target) { process.stderr.write('usage: node validate.mjs <session>/step-N/parallel-M\n'); process.exit(2); }
238
- const { errors } = await validateBackendStep(path.resolve(target));
239
- if (errors.length) { process.stderr.write(`${errors.join('\n')}\n`); process.exitCode = 1; } else process.stdout.write('valid backend.source.apply branch\n');
240
- }
1
+ // backend.source.apply's own law over one branch, on top of the shared step check: the write set arrives
2
+ // as exactly one commit on the session branch, and the mutation record, response.json.commits and the
3
+ // change record name that same sha; every operation the frozen contract carries is restated once and
4
+ // applied; every change lies inside the mutable ceiling, names a declared operation, appears once, and
5
+ // carries the hash pair its kind demands; a dry run commits nothing, writes no after hash, measures no
6
+ // facet and runs no proof, and reports every planned path as unchanged in the change record; every
7
+ // declared facet of an applied run has its own conformance file, named for
8
+ // the operation and facet it measures, with evidence, and every verdict in a done branch conforms;
9
+ // every declared proof ran exactly once in its own file, with a command, an exit code that agrees with
10
+ // its result, and a pass; the change record and the receipt describe the same files; no finding names
11
+ // an undeclared operation, and a done branch raises no business question.
12
+ import { existsSync } from 'node:fs';
13
+ import { readFile } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import process from 'node:process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { validateStep } from '../../scripts/validate-step.mjs';
18
+ import { tableUnder } from '../../scripts/validate-response.mjs';
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
21
+ // A change record is the only durable trace that a file was mutated, so the hash pair has to agree with
22
+ // the kind. A modified record whose before and after hashes are equal describes a mutation that never
23
+ // happened.
24
+ const HASH_SHAPE = { added: { before: 'null', after: 'set' }, modified: { before: 'set', after: 'set' }, deleted: { before: 'set', after: 'null' } };
25
+ const empty = (v) => v === undefined || v === null || v === '' || v === '—';
26
+ const fields = (rows) => Object.fromEntries((rows ?? []).map(([k, v]) => [k, v]));
27
+ const listed = (value) => (value === undefined ? [] : Array.isArray(value) ? value : [value]);
28
+
29
+ export async function validateBackendStep(branchDir, root = ROOT) {
30
+ const base = await validateStep(root, branchDir);
31
+ const errors = [...base.errors];
32
+ const { response, requirements = {}, present = new Set() } = base;
33
+ if (!response || response.operatorId !== 'backend.source.apply') return { errors };
34
+ const has = (f) => existsSync(path.join(branchDir, f));
35
+ const read = (f) => readFile(path.join(branchDir, f), 'utf8');
36
+ const readJson = async (f) => { try { return JSON.parse(await read(f)); } catch { return null; } };
37
+ const mutable = new Set(Array.isArray(requirements.mutableFileRefs) ? requirements.mutableFileRefs : []);
38
+ // `mode` decides whether this branch touched the checkout at all; everything below reads differently under dry.
39
+ const mode = requirements.mode ?? 'apply';
40
+
41
+ // A blocked branch has no implementation at all, and nothing it may have written is committed.
42
+ if (response.status === 'blocked' && (present.has('backend-source-application') || present.has('mutations') || present.has('conformance') || present.has('proof'))) {
43
+ errors.push('response/response.json: a blocked branch cannot carry an implementation');
44
+ }
45
+ if (response.status === 'blocked' && (response.commits ?? []).length) {
46
+ errors.push('response/response.json: a blocked branch commits nothing');
47
+ }
48
+
49
+ const mutations = present.has('mutations') && has('response/data/mutations.json') ? await readJson('response/data/mutations.json') : null;
50
+ const declared = mutations ? mutations.operations : [];
51
+ const declaredById = new Map(declared.map((o) => [o.operationId, o]));
52
+
53
+ // The plan cannot re-decide the mode the request asked for.
54
+ if (mutations && mutations.mode !== mode) errors.push(`response/data/mutations.json: mode ${mutations.mode} differs from the request's ${mode}`);
55
+
56
+ // A dry run answers with the plan alone: nothing committed, nothing measured, nothing hashed after.
57
+ if (mode === 'dry') {
58
+ if (mutations && mutations.commit !== null) errors.push('response/data/mutations.json: a dry run commits nothing, so commit must be null');
59
+ if ((response.commits ?? []).length) errors.push('response/response.json: a dry run records no commit');
60
+ if (present.has('conformance') || present.has('proof')) errors.push('response/response.json: a dry run measures nothing, so it carries no conformance or proof record');
61
+ }
62
+
63
+ // One commit on the session branch, named the same way in three places.
64
+ if (response.status === 'done' && mode === 'apply') {
65
+ const commits = response.commits ?? [];
66
+ if (commits.length !== 1) errors.push(`response/response.json: a done branch commits its whole write set once, found ${commits.length} commits`);
67
+ if (mutations && commits.length === 1 && mutations.commit !== commits[0]) errors.push(`response/data/mutations.json: commit ${mutations.commit} differs from response.json commits[0] ${commits[0]}`);
68
+ // Required of an applied branch only: a dry run has no source to measure, and the Outputs table can only say yes or no.
69
+ for (const kind of ['conformance', 'proof']) if (!present.has(kind)) errors.push(`response/response.json: required output ${kind} is not in fields`);
70
+ }
71
+ if (mutations) {
72
+ if (mode === 'apply' && mutations.commit === mutations.base) errors.push('response/data/mutations.json: the commit equals the base, so nothing was written on the session branch');
73
+ for (const operation of declared) {
74
+ const at = `response/data/mutations.json: operation ${operation.operationId}`;
75
+ if (mutable.size && !mutable.has(operation.writerRef)) errors.push(`${at} names writer ${operation.writerRef} outside the mutable ceiling`);
76
+ const shipsMigration = (operation.migrationRefs ?? []).length > 0;
77
+ // A migration without a replay proof is a schema change nobody re-ran.
78
+ if (shipsMigration && !operation.proofKinds.includes('migration-replay')) errors.push(`${at} ships a migration without declaring the migration-replay proof`);
79
+ if (shipsMigration && !operation.facets.includes('migration')) errors.push(`${at} ships a migration without declaring the migration facet`);
80
+ if (!shipsMigration && operation.proofKinds.includes('migration-replay')) errors.push(`${at} declares a migration-replay proof but ships no migration`);
81
+ // A mutation arriving through a boundary declared not to mutate.
82
+ if (operation.transactionBoundary === 'read-only' && shipsMigration) errors.push(`${at} is read-only but ships a migration`);
83
+ // A redelivered event applies twice unless something makes the write idempotent.
84
+ if (operation.transport === 'event-consumer' && operation.idempotencyKind === 'none') errors.push(`${at} consumes events with no idempotency and will apply twice on redelivery`);
85
+ if (new Set(operation.facets).size !== operation.facets.length) errors.push(`${at} repeats a contract facet`);
86
+ if (new Set(operation.proofKinds).size !== operation.proofKinds.length) errors.push(`${at} repeats a proof kind`);
87
+ if (new Set(operation.authorityDimensionIds).size !== operation.authorityDimensionIds.length) errors.push(`${at} repeats a business dimension identifier`);
88
+ }
89
+ if (declared.length && declaredById.size !== declared.length) errors.push('response/data/mutations.json: the contract repeats an operationId');
90
+
91
+ const changedPaths = new Set();
92
+ for (const change of mutations.changes) {
93
+ if (changedPaths.has(change.path)) errors.push(`response/data/mutations.json: file ${change.path} carries more than one change record`);
94
+ changedPaths.add(change.path);
95
+ if (declaredById.size && !declaredById.has(change.operationId)) errors.push(`response/data/mutations.json: change on ${change.path} names undeclared operation ${change.operationId}`);
96
+ if (mutable.size && !mutable.has(change.path)) errors.push(`response/data/mutations.json: change on ${change.path} lies outside the mutable ceiling`);
97
+ const shape = HASH_SHAPE[change.change];
98
+ if ((shape.before === 'set') !== (change.beforeHash !== null)) errors.push(`response/data/mutations.json: change on ${change.path} is ${change.change} with the wrong before hash`);
99
+ if (mode === 'dry') {
100
+ // A planned path has no new content yet, so an after hash could only have been invented.
101
+ if (change.afterHash !== null) errors.push(`response/data/mutations.json: change on ${change.path} reports an after hash under a dry run, which writes nothing`);
102
+ } else {
103
+ if ((shape.after === 'set') !== (change.afterHash !== null)) errors.push(`response/data/mutations.json: change on ${change.path} is ${change.change} with the wrong after hash`);
104
+ if (change.change === 'modified' && change.beforeHash === change.afterHash) errors.push(`response/data/mutations.json: change on ${change.path} records a modification whose hashes are identical`);
105
+ }
106
+ }
107
+ }
108
+
109
+ // One conformance file per declared facet, named for what it measures.
110
+ const conformanceKeys = new Set();
111
+ for (const file of listed(response.fields?.conformance)) {
112
+ const record = has(file) ? await readJson(file) : null;
113
+ if (!record) continue;
114
+ const expected = `response/data/conformance/${record.operationId}.${record.facet}.json`;
115
+ if (file !== expected) errors.push(`${file}: a conformance record of ${record.operationId} ${record.facet} must be filed as ${expected}`);
116
+ const key = `${record.operationId}|${record.facet}`;
117
+ if (conformanceKeys.has(key)) errors.push(`${file}: operation ${record.operationId} records ${record.facet} conformance twice`);
118
+ conformanceKeys.add(key);
119
+ const operation = declaredById.get(record.operationId);
120
+ if (declaredById.size && !operation) { errors.push(`${file}: conformance names undeclared operation ${record.operationId}`); continue; }
121
+ if (operation && !operation.facets.includes(record.facet)) errors.push(`${file}: operation ${record.operationId} proves undeclared facet ${record.facet}`);
122
+ if (mutations && record.contractFingerprint !== mutations.contractFingerprint) errors.push(`${file}: measured against another contract fingerprint than the one filled`);
123
+ // A widened or narrowed facet means the frozen contract was not filled as written.
124
+ if (response.status === 'done' && record.verdict !== 'conforms') errors.push(`${file}: operation ${record.operationId} reports ${record.verdict} ${record.facet} conformance in a done branch`);
125
+ if (empty(record.evidenceRef)) errors.push(`${file}: operation ${record.operationId} asserts ${record.facet} conformance with no evidence`);
126
+ }
127
+
128
+ const proofKeys = new Set();
129
+ for (const file of listed(response.fields?.proof)) {
130
+ const record = has(file) ? await readJson(file) : null;
131
+ if (!record) continue;
132
+ const expected = `response/data/proofs/${record.operationId}.${record.proofKind}.json`;
133
+ if (file !== expected) errors.push(`${file}: a proof of ${record.operationId} ${record.proofKind} must be filed as ${expected}`);
134
+ const key = `${record.operationId}|${record.proofKind}`;
135
+ if (proofKeys.has(key)) errors.push(`${file}: operation ${record.operationId} records the ${record.proofKind} proof twice`);
136
+ proofKeys.add(key);
137
+ const operation = declaredById.get(record.operationId);
138
+ if (declaredById.size && !operation) { errors.push(`${file}: proof names undeclared operation ${record.operationId}`); continue; }
139
+ if (operation && !operation.proofKinds.includes(record.proofKind)) errors.push(`${file}: operation ${record.operationId} runs undeclared proof ${record.proofKind}`);
140
+ if (mutations && record.contractFingerprint !== mutations.contractFingerprint) errors.push(`${file}: run against another contract fingerprint than the one filled`);
141
+ // The exit code is what was actually read; the verdict is a reading of it.
142
+ if ((record.exitCode === 0) !== (record.result === 'passed')) errors.push(`${file}: exit code ${record.exitCode} contradicts the ${record.result} verdict`);
143
+ if (response.status === 'done' && record.result !== 'passed') errors.push(`${file}: operation ${record.operationId} reports a failed ${record.proofKind} proof in a done branch`);
144
+ }
145
+
146
+ if (response.status === 'done' && mode === 'apply') {
147
+ for (const operation of declared) {
148
+ // Silence about a facet reads exactly like a pass.
149
+ for (const facet of operation.facets) if (!conformanceKeys.has(`${operation.operationId}|${facet}`)) errors.push(`response/data/conformance/${operation.operationId}.${facet}.json: the operation declares the ${facet} facet and no record proves it`);
150
+ for (const proofKind of operation.proofKinds) if (!proofKeys.has(`${operation.operationId}|${proofKind}`)) errors.push(`response/data/proofs/${operation.operationId}.${proofKind}.json: the operation declares the ${proofKind} proof and never ran it`);
151
+ }
152
+ }
153
+
154
+ let receiptFiles = null;
155
+ if (present.has('backend-source-application') && has('response/response.md')) {
156
+ const text = await read('response/response.md');
157
+ const binding = fields(tableUnder(text, '## Binding'));
158
+ if (!empty(requirements.outcome) && binding.Outcome !== requirements.outcome) errors.push('response/response.md: Outcome differs from the request');
159
+ if (!empty(requirements.featureId) && binding.Feature !== requirements.featureId) errors.push('response/response.md: Feature differs from the request');
160
+ if (binding.Mode !== undefined && binding.Mode !== mode) errors.push(`response/response.md: Mode ${binding.Mode} differs from the request's ${mode}`);
161
+ if (mutations) {
162
+ if (binding['Contract fingerprint'] !== mutations.contractFingerprint) errors.push('response/response.md: Contract fingerprint differs from the fingerprint the mutations were measured against');
163
+ if (binding.Base !== mutations.base) errors.push('response/response.md: Base differs from the base the session branch was cut from');
164
+ if (binding.Branch !== mutations.branch) errors.push('response/response.md: Branch differs from the session branch the write set lives on');
165
+ if (binding.Commit !== (mutations.commit ?? '—')) errors.push('response/response.md: Commit differs from the one commit the write set arrived as');
166
+ }
167
+
168
+ const applied = new Set();
169
+ for (const [operationId, transport, writer, transaction, idempotency, decisions] of tableUnder(text, '## Operations') ?? []) {
170
+ if (applied.has(operationId)) errors.push(`response/response.md: operation ${operationId} is declared more than once`);
171
+ applied.add(operationId);
172
+ const operation = declaredById.get(operationId);
173
+ if (declaredById.size && !operation) { errors.push(`response/response.md: operation ${operationId} is not in the frozen contract`); continue; }
174
+ if (!operation) continue;
175
+ if (transport !== operation.transport) errors.push(`response/response.md: operation ${operationId} reports transport ${transport}, the contract froze ${operation.transport}`);
176
+ if (writer !== operation.writerRef) errors.push(`response/response.md: operation ${operationId} reports writer ${writer}, the contract froze ${operation.writerRef}`);
177
+ if (transaction !== operation.transactionBoundary) errors.push(`response/response.md: operation ${operationId} reports transaction ${transaction}, the contract froze ${operation.transactionBoundary}`);
178
+ if (idempotency !== operation.idempotencyKind) errors.push(`response/response.md: operation ${operationId} reports idempotency ${idempotency}, the contract froze ${operation.idempotencyKind}`);
179
+ const cited = String(decisions).split(',').map((s) => s.trim()).filter(Boolean);
180
+ for (const dimensionId of cited) if (!operation.authorityDimensionIds.includes(dimensionId)) errors.push(`response/response.md: operation ${operationId} cites dimension ${dimensionId}, which the contract does not bind`);
181
+ for (const dimensionId of operation.authorityDimensionIds) if (!cited.includes(dimensionId)) errors.push(`response/response.md: operation ${operationId} does not restate approved dimension ${dimensionId}`);
182
+ }
183
+ if (response.status === 'done') for (const operationId of declaredById.keys()) if (!applied.has(operationId)) errors.push(`response/response.md: operation ${operationId} was declared but never applied`);
184
+
185
+ const seenFiles = new Set();
186
+ const changeById = new Map((mutations?.changes ?? []).map((c) => [c.path, c]));
187
+ for (const [file, changeKind, operationId, before, after] of tableUnder(text, '## Changes') ?? []) {
188
+ if (seenFiles.has(file)) errors.push(`response/response.md: file ${file} carries more than one change record`);
189
+ seenFiles.add(file);
190
+ const record = changeById.get(file);
191
+ if (mutations && !record) { errors.push(`response/response.md: change on ${file} is absent from the mutation record`); continue; }
192
+ if (!record) continue;
193
+ if (changeKind !== record.change) errors.push(`response/response.md: change on ${file} is ${changeKind} here and ${record.change} in the mutation record`);
194
+ if (operationId !== record.operationId) errors.push(`response/response.md: change on ${file} names operation ${operationId}, the mutation record names ${record.operationId}`);
195
+ if ((empty(before) ? null : before) !== record.beforeHash) errors.push(`response/response.md: change on ${file} disagrees with the mutation record on the before hash`);
196
+ if ((empty(after) ? null : after) !== record.afterHash) errors.push(`response/response.md: change on ${file} disagrees with the mutation record on the after hash`);
197
+ }
198
+ if (mutations) for (const p of changeById.keys()) if (!seenFiles.has(p)) errors.push(`response/response.md: the mutation record changed ${p}, which the receipt omits`);
199
+ receiptFiles = seenFiles;
200
+
201
+ for (const [code, operationId] of tableUnder(text, '## Findings') ?? []) {
202
+ if (!empty(operationId) && declaredById.size && !declaredById.has(operationId)) errors.push(`response/response.md: finding ${code} names undeclared operation ${operationId}`);
203
+ // Raising the business question and shipping anyway is the exact contradiction to catch.
204
+ if (code === 'BUSINESS_QUESTION_RAISED' && response.status === 'done') errors.push('response/response.md: a done branch cannot raise an unresolved business question');
205
+ }
206
+ }
207
+
208
+ // The change record the next step reads must describe the same files and pin the same commit.
209
+ if (present.has('changes') && has('response/changes.md')) {
210
+ const text = await read('response/changes.md');
211
+ if (mutations) {
212
+ const checkout = fields(tableUnder(text, '## Binding')).Checkout ?? '';
213
+ // A dry run wrote nothing, so the next request can only pin the base it read.
214
+ const expected = mode === 'dry'
215
+ ? `@workspaces/be at ${mutations.base} on ${mutations.branch}, nothing written`
216
+ : `@workspaces/be at ${mutations.base} → ${mutations.commit} on ${mutations.branch}`;
217
+ const seen = checkout.replaceAll('`', '').replace(/\s+/g, ' ').trim();
218
+ if (seen !== expected) errors.push(`response/changes.md: Checkout must read ${expected}, so the next request can pin exactly what was written; it reads ${seen}`);
219
+ }
220
+ if (mode === 'dry') {
221
+ // The Change column reports the working tree, and a dry run left every path in it alone.
222
+ for (const [file, kind] of tableUnder(text, '## Files') ?? []) {
223
+ if (kind !== 'unchanged') errors.push(`response/changes.md: ${file} is reported ${kind} under a dry run, which leaves every path unchanged in the working tree`);
224
+ }
225
+ }
226
+ if (receiptFiles) {
227
+ const changed = new Set((tableUnder(text, '## Files') ?? []).map(([p]) => p));
228
+ for (const file of changed) if (!receiptFiles.has(file)) errors.push(`response/changes.md: ${file} is not in the receipt's change records`);
229
+ for (const file of receiptFiles) if (!changed.has(file)) errors.push(`response/changes.md: the receipt changed ${file}, which the change record omits`);
230
+ }
231
+ }
232
+ return { errors };
233
+ }
234
+
235
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
236
+ const target = process.argv[2];
237
+ if (!target) { process.stderr.write('usage: node validate.mjs <session>/step-N/parallel-M\n'); process.exit(2); }
238
+ const { errors } = await validateBackendStep(path.resolve(target));
239
+ if (errors.length) { process.stderr.write(`${errors.join('\n')}\n`); process.exitCode = 1; } else process.stdout.write('valid backend.source.apply branch\n');
240
+ }
@@ -142,7 +142,7 @@ function writeBranch(files) {
142
142
  for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
143
143
  mkdirSync(path.join(session, 'step-1', 'parallel-2', 'response'), { recursive: true });
144
144
  writeFileSync(path.join(session, DELIVERED), '# backend-source-application — delivered source\n');
145
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'business.decide' }, current: '1/1', status: 'running' }));
145
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'business.decide' }, current: '1/1', status: 'running' }));
146
146
  for (const [name, content] of Object.entries(files)) {
147
147
  if (content === null) continue;
148
148
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -217,6 +217,7 @@ await expectError({ ...baseline(), 'request/request.json': requestJson({ extra:
217
217
  await expectError({ ...baseline(), 'response/response.md': responseMd({ dispositions: DISPOSITIONS.map(([d, p]) => (d === 'legacy-read' ? [d, 'defer'] : [d, p])) }) }, 'is defer here and preserve in the matrix', 'the response and the matrix disagree on a disposition');
218
218
  await expectError({ ...baseline(), 'response/response.md': responseMd().replace('## Coverage', '## Coverage matrix') }, 'missing section ^## Coverage$', 'response section renamed');
219
219
  await expectError({ ...baseline(), 'response/response.md': responseMd({ findings: [['LEGACY_COEXISTENCE', 'error', 'legacy-create', 'the legacy sale path still creates rights']] }) }, 'is still an open error, so the promise cannot be published', 'published with an open error finding');
220
+ await expectError({ ...baseline(), 'response/data/claims.json': claimsDoc({ fingerprint: `sha256:${'b'.repeat(64)}` }) }, 'claimsFingerprint must equal the frozen claims fingerprint', 'a head naming another claims fingerprint');
220
221
  await expectError({ ...baseline(), 'response/data/claims.json': claimsDoc({ claims: [CLAIMS[0], { ...CLAIMS[0], kind: 'intent' }] }) }, 'is declared more than once', 'a duplicated claim');
221
222
  await expectError({ ...baseline(), 'response/data/claims.json': claimsDoc({ claims: [{ ...CLAIMS[0], sourceHead: null }, CLAIMS[1]] }) }, 'must bind the observed source head', 'a fact claim with no head');
222
223
  await expectError({ ...baseline(), 'response/response.md': responseMd({ claims: [['c-fact', 'fact']] }) }, 'which Cited claims omits', 'a claim the response never cites');
@@ -71,10 +71,12 @@ export async function validateBusinessStep(branchDir, root = ROOT) {
71
71
  const declaredDimensions = Array.isArray(requirements.dimensions) ? requirements.dimensions : [];
72
72
 
73
73
  let claims = new Map();
74
+ let claimsDoc = null;
74
75
  if (present.has('claims') && has('response/data/claims.json')) {
75
76
  let doc = null;
76
77
  try { doc = JSON.parse(await read('response/data/claims.json')); } catch { doc = null; }
77
78
  if (doc) {
79
+ claimsDoc = doc;
78
80
  if (!empty(requirements.featureId) && doc.featureId !== requirements.featureId) errors.push(`response/data/claims.json: featureId ${doc.featureId} differs from the request's ${requirements.featureId}`);
79
81
  for (const claim of doc.claims ?? []) {
80
82
  if (claims.has(claim.claimId)) errors.push(`response/data/claims.json: claim ${claim.claimId} is declared more than once`);
@@ -197,6 +199,7 @@ export async function validateBusinessStep(branchDir, root = ROOT) {
197
199
  if (model.lineage.previousState !== null && model.lineage.previousHeadRef === null) errors.push('response/data/model.json: a transition from an existing state must name the previous head, because rejection preserves lineage');
198
200
 
199
201
  if (matrix && model.coverageFingerprint !== matrix.fingerprint) errors.push('response/data/model.json: coverageFingerprint must equal the frozen matrix fingerprint, or backend and UAT cannot correlate the same matrix');
202
+ if (claimsDoc && model.claimsFingerprint !== claimsDoc.fingerprint) errors.push('response/data/model.json: claimsFingerprint must equal the frozen claims fingerprint, or the head names claims nobody froze');
200
203
  if (mode === 'model' && model.reconciliation !== null) errors.push('response/data/model.json: mode model reconciles nothing, so reconciliation must be null');
201
204
  if (mode === 'reconcile') {
202
205
  if (model.reconciliation === null) errors.push('response/data/model.json: mode reconcile must carry the reconciliation it performed');
@@ -176,7 +176,7 @@ function writeBranch(files) {
176
176
  const session = mkdtempSync(path.join(tmpdir(), 'content-session-'));
177
177
  const branch = path.join(session, 'step-1', 'parallel-1');
178
178
  for (const d of ['request', 'response/data', 'response/artifacts', 'review/request', 'review/response']) mkdirSync(path.join(branch, d), { recursive: true });
179
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'content.generate' }, current: '1/1', status: 'running' }));
179
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'content.generate' }, current: '1/1', status: 'running' }));
180
180
  for (const [name, content] of Object.entries(files)) {
181
181
  if (content === null) continue;
182
182
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -146,7 +146,7 @@ function writeBranch(files) {
146
146
  for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
147
147
  mkdirSync(path.join(session, 'step-1', 'parallel-1', 'response'), { recursive: true });
148
148
  writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'response.md'), '# business-promise-authority — plans\n');
149
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1'], ['2/1']], steps: { '1/1': 'business.decide', '2/1': 'frontend.direction.decide' }, current: '2/1', status: 'running' }));
149
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1'], ['2/1']], steps: { '1/1': 'business.decide', '2/1': 'frontend.direction.decide' }, current: '2/1', status: 'running' }));
150
150
  for (const [name, content] of Object.entries(files)) {
151
151
  if (content === null) continue;
152
152
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -108,6 +108,12 @@ write product source, or record a verdict, score or pass claim on any node.
108
108
  | 8 | Emit the contract claims onto the application-owned nodes | `contractEmission` | @knowledge/ui/presentation (the frozen rule inventory) | — | `UNKNOWN_RULE` |
109
109
  | 9 | Emit | — | everything above | `response/artifacts/<target>.resolved.tsx`, `response/data/inventory.json`, `response/response.md`, `response/response.json` | — |
110
110
 
111
+ Step 8 claims only what a node can carry: a property the application owns on a Grammar component's
112
+ `className` carries no attribute, because the component forwards `className` and publishes no prop for
113
+ the relationship, so its rule is recorded under `## Gaps` instead of being emitted. Forwarding
114
+ `data-contract` from every Common component that accepts `className` is a Grammar change and belongs
115
+ to the family owner, not to this operator.
116
+
111
117
  The walk visits every node in document order and records a stable node path; a node outside the
112
118
  ceiling the direction carries is observed and never mutated. Steps 4 to 8 run per node, so a single
113
119
  tree yields one decision per node and property. The receipt authorizes a later audit to measure the
@@ -106,6 +106,12 @@ source sản phẩm, và không ghi phán quyết, điểm số hay tuyên bố
106
106
  | 8 | Phát lời khai contract lên các node do ứng dụng sở hữu | `contractEmission` | @knowledge/ui/presentation (kho luật đã đóng băng) | — | `UNKNOWN_RULE` |
107
107
  | 9 | Phát | — | mọi thứ ở trên | `response/artifacts/<target>.resolved.tsx`, `response/data/inventory.json`, `response/response.md`, `response/response.json` | — |
108
108
 
109
+ Bước 8 chỉ khai thứ mà một node mang nổi: một thuộc tính do ứng dụng sở hữu nằm trên `className` của
110
+ một component Grammar thì không mang thuộc tính nào, bởi component ấy chuyển tiếp `className` và không
111
+ công bố prop nào cho quan hệ đó, nên luật của nó được ghi dưới `## Gaps` thay vì được phát ra. Chuyển
112
+ tiếp `data-contract` từ mọi component Common có nhận `className` là một thay đổi của Grammar và thuộc
113
+ về chủ họ ấy, không thuộc về operator này.
114
+
109
115
  Lượt đi thăm mọi node theo thứ tự tài liệu và ghi một node path ổn định; node nằm ngoài trần mà hướng
110
116
  mang theo thì chỉ quan sát chứ không bao giờ bị sửa. Bước 4 đến 8 chạy theo từng node, nên một cây cho
111
117
  đúng một quyết định cho mỗi cặp node và thuộc tính. Biên nhận uỷ quyền cho một lượt audit sau đo kết