Setting the file. One moment.
Read Back Contract · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page — line 75
This file
Number 45.132
Position 132 of 182
Type JavaScript
Size 39 KB
Lines 634 lib/ read-back-contract.js
JavaScript · 634 lines · 39 KB
9 const state = require ( './read-back-state' );
10 const {
11 GUID
12 } = require ( './operator-identity' );
13 const DOMAINS = {
14 order: 'ecom' ,
15 member: 'members' ,
16 contact: 'crm' ,
17 product: 'stores'
18 };
19 const L = require ( './source-location' );
20 const INVENTORY_SCHEMA_VERSION = 2 ;
21 // One shared location grammar (lib/source-location.js). Legacy [n] indexes normalize to [].
22 const canonical = p => L . canonicalize ( String (p));
23 const digest = value => crypto. createHash ( 'sha256' ). update ( JSON . stringify (value)). digest ( 'hex' );
24 const populated = L .populated;
25 const compareKeys = ( a , b ) => Buffer. compare (Buffer. from ( String (a)), Buffer. from ( String (b)));
26 function entityKey ( entity ) {
27 if ( ! Object. hasOwn ( DOMAINS , entity)) throw new Error ( 'Unsupported read-back entity' );
28 return entity;
29 }
30 function domainPath ( entity ) {
31 return path. join (__dirname, '../resources/rp-target-wix/domains' , DOMAINS [ entityKey (entity)], 'entities' , `${ entity }.json` );
32 }
33 function domainContract ( entity ) {
34 const file = domainPath (entity);
35 const d = JSON . parse (fs. readFileSync (file, 'utf8' ));
36 return {
37 intendedFields: d.intendedFields,
38 readBackProjection: d.readBackProjection,
39 domainDigest: hashArtifact (path. dirname (file), path. basename (file))
40 };
41 }
42 function decisionFileName ( projectDir , entity ) {
43 let plan = null ;
44 try { plan = JSON . parse (fs. readFileSync (path. join (projectDir, 'mapping/mapping-plan.json' ), 'utf8' )); } catch { /* legacy fallback */ }
45 const preferred = plan?.fieldContractVersion === 1
46 ? require ( './mapping-field-contract' ). entityDecisionFileName (plan, entity)
47 : `${ entity }.json` ;
48 // Exact plans are source-named. Keep reading an existing target-named projection
49 // from older exact fixtures/runs until the mapper rewrites it.
50 const legacy = `${ entity }.json` ;
51 return fs. existsSync (path. join (projectDir, 'mapping/entity-decisions' , preferred)) || ! fs. existsSync (path. join (projectDir, 'mapping/entity-decisions' , legacy))
52 ? preferred
53 : legacy;
54 }
55 function bindingPaths ( entity , plan , projectDir = null ) {
56 entityKey (entity);
57 const decision = plan?.fieldContractVersion === 1
58 ? projectDir ? decisionFileName (projectDir, entity) : require ( './mapping-field-contract' ). entityDecisionFileName (plan, entity)
59 : `${ entity }.json` ;
60 return [ 'mapping/mapping-plan.json' , `mapping/entity-decisions/${ decision }` , 'mapping/mapping-resolution.json' , `execution/read-back-inventory/${ entity }.json` ];
61 }
62 function bindings ( projectDir , entity ) {
63 let plan = null ;
64 try { plan = JSON . parse (fs. readFileSync (path. join (projectDir, 'mapping/mapping-plan.json' ), 'utf8' )); } catch { /* the missing plan is reported by its own binding */ }
65 return Object. fromEntries ( bindingPaths (entity, plan, projectDir). map ( p => {
66 const hash = hashArtifact (projectDir, p);
67 if ( ! hash) throw new Error ( `read-back binding missing: ${ p }` );
68 return [p, hash];
69 }));
70 }
71 function leafPaths ( value , { templates = [] } = {}) {
72 return L . leafLocations (value, { templates });
73 }
74 const valuesAt = ( value , location ) => L . valuesAt (value, location);
75 function planEntity ( plan , entity ) {
76 const fields = require ( './mapping-field-contract' );
77 return fields. entityRows (plan || {}). find ( row => fields. entityKeyMatches (row, entity)) || null ;
78 }
79 // Selector templates come only from the reviewed coverage's dynamic entries. Two different
80 // templates for one array are ambiguous and fail closed.
81 function selectorTemplates ( plan , entity ) {
82 const row = planEntity (plan, entity), byArray = new Map ();
83 for ( const item of row?.discoveryCoverage?.paths || []) {
84 if ( ! item || typeof item !== 'object' || item.dynamic !== true ) continue ;
85 const t = L . template (item), prior = byArray. get (t.array);
86 if (prior && (prior.keyPath !== t.keyPath || prior.value !== t.value)) throw Object. assign ( new Error ( `Ambiguous selector templates for ${ t . array }` ), { code: 'selector_template_ambiguous' });
87 byArray. set (t.array, t);
88 }
89 return [ ... byArray. values ()]. sort (( a , b ) => compareKeys (a.path, b.path));
90 }
91 // Draft helper (read-only): every inventoried location under a selected metadata value that no
92 // decision claims, grouped per selected value into an UNRESOLVED stub. It never excludes,
93 // approves or writes; the mapper decides each stub and keeps every leaf as an exact location.
94 function draftSelectorDescendantStubs ( plan , entity , inv ) {
95 const fields = require ( './mapping-field-contract' ), row = planEntity (plan, entity);
96 const claimed = new Set ((row?.fieldMappings || []). flatMap ( d => { try { return fields. decisionLocations (d); } catch { return []; } }));
97 const groups = new Map ();
98 for ( const p of Object. keys (inv?.sourcePaths || {}). sort (compareKeys)) {
99 if (claimed. has (p)) continue ;
100 const segs = L . parse (p), at = segs. findIndex ( s => s.where);
101 if (at < 0 || at + 1 >= segs. length ) continue ;
102 const root = L . format (segs. slice ( 0 , at + 2 ));
103 if ( ! groups. has (root)) groups. set (root, []);
104 groups. get (root). push (p);
105 }
106 return [ ... groups. entries ()]. map (([ root , sourcePaths ]) => ({
107 id: `selector-descendants-${ fields . digest ( root ). slice ( 0 , 12 ) }` ,
108 disposition: 'unresolved' ,
109 sourcePaths,
110 evidence: [{ kind: 'read-back-inventory' , ref: `execution/read-back-inventory/${ entity }.json` }],
111 reason: `Populated locations under ${ L . readable ( root ) } were inventoried but no decision claims them; decide each exactly.` ,
112 suggestedDecisionRefs: (row?.fieldMappings || []). filter ( d => { try { return fields. decisionLocations (d). includes (root); } catch { return false ; } }). map ( d => d.id). sort (),
113 inventoryCounts: Object. fromEntries (sourcePaths. map ( p => [p, inv.sourcePaths[p]])),
114 draft: true
115 }));
116 }
117 // The pre-approval builder is a pure record -> request-body function. It is prepared from
118 // draft decisions before approval, so it must not reach a Wix client, capability, executor,
119 // network or process API. This is a deterministic static refusal, not a sandbox.
120 const IMPURE_BUILDER = [
121 [ / \b require \s * \( \s * ['"] (?:node:) ? (?:http | https | http2 | net | tls | dgram | dns | child_process | worker_threads | cluster) ['"] / , 'network or process module' ],
122 [ / \b import \b [ ^ ;] * ['"] (?:node:) ? (?:http | https | http2 | net | tls | dgram | dns | child_process | worker_threads | cluster) ['"] / , 'network or process module' ],
123 [ / \b fetch \s * \( |\b XMLHttpRequest \b|\b WebSocket \b / , 'network call' ],
124 [ /wix-writers | createWixClient | wix-client | wix-http | dependency-gate | dependency-readiness | execution-capabilit | import-executor | run-import | issueCapability | withCapability | gateBatch | executeCanaries | ReadBackGate | writeFile | appendFile |\b process \. env \b / , 'Wix client, capability, executor or write wiring' ]
125 ];
126 function assertPureBuilder ( sourceText ) {
127 for ( const [ pattern , what ] of IMPURE_BUILDER ) if (pattern. test (sourceText)) throw Object. assign ( new Error ( `builder is not pure: it references ${ what }` ), { code: 'builder_not_pure' });
128 return true ;
129 }
130 const templateKey = templates => templates. map ( t => ({ path: t.path, keyPath: t.keyPath }));
131 // Declared output applicability: the exact predicates an inventory was measured
132 // under. A change to any of them makes that inventory stale.
133 const applicabilityKey = rows => rows. filter ( r => r.appliesWhen !== undefined )
134 . map ( r => [ String (r.canonicalField), canonical (r.writePath), r.appliesWhen]). sort (( a , b ) => compareKeys (a[ 0 ], b[ 0 ]));
135 function assertApplicabilityShape ( rows ) {
136 const A = require ( './output-applicability' );
137 for ( const r of rows) if (r.appliesWhen !== undefined ) {
138 const errors = A . validate (r.appliesWhen, [ ... A . locations (r.appliesWhen)]);
139 if (errors. length ) throw Object. assign ( new Error ( `Invalid output applicability ${ r . canonicalField }: ${ errors . join ( '; ' ) }` ), { code: 'applies_when_invalid' });
140 }
141 }
142 // One record: which outputs the builder must write (applicable and fed by a populated source)
143 // and which it must not write (every row at that write path declares appliesWhen and none
144 // applies). Rows without appliesWhen keep the former rule: always applicable. Outputs sharing a
145 // write path are judged together; writer-guard paths are excluded by the caller.
146 function outputApplicability ( rows , record , expected , { guarded = [], declaredOnly = false } = {}) {
147 const A = require ( './output-applicability' ), groups = new Map (), states = [], missing = [], unexpected = [];
148 for ( const row of rows) {
149 if (guarded. includes (row.writePath)) continue ;
150 if ( ! groups. has (row.writePath)) groups. set (row.writePath, []);
151 groups. get (row.writePath). push (row);
152 }
153 for ( const [ writePath , group ] of groups) {
154 const at = group. map ( row => ({ row, declared: row.appliesWhen !== undefined ,
155 applies: row.appliesWhen === undefined || A . evaluate (row.appliesWhen, record),
156 fed: (row.sourcePaths || []). some ( p => valuesAt (record, p). some (populated)) }));
157 const emitted = valuesAt (expected, writePath). some (populated);
158 const required = at. filter ( s => s.applies && s.fed && ( ! declaredOnly || s.declared));
159 if ( ! emitted && required. length ) missing. push ( ... required. map ( s => s.row));
160 if (emitted && at. every ( s => s.declared && ! s.applies)) unexpected. push ( ... at. map ( s => s.row));
161 for ( const s of at) states. push ({ ... s, emitted });
162 }
163 return { states, missing, unexpected };
164 }
165 // A decided hold: the generated pure builder returns `{ status: 'held', holds: [{ decisionId,
166 // code }] }` for a record it will not write. Returns the sorted `decisionId:code` reasons, or
167 // null for a writable body. A hold without a reason per entry is refused.
168 // With `decisionIds`, each hold must name one of those decisions.
169 function builderHold ( body , decisionIds = null ) {
170 if ( ! body || typeof body !== 'object' || body.status !== 'held' && ! (Array. isArray (body.holds) && body.holds. length )) return null ;
171 const named = v => typeof v === 'string' && v. trim () !== '' ;
172 const reasons = (Array. isArray (body.holds) ? body.holds : []). map ( h => named (h?.decisionId) && named (h?.code) ? `${ h . decisionId }:${ h . code }` : null );
173 if (body.status !== 'held' || ! reasons. length || reasons. includes ( null )) throw Object. assign ( new Error ( 'Builder hold requires status held and a decisionId and code per hold' ), { code: 'builder_hold_invalid' });
174 if (decisionIds && body.holds. some ( h => ! decisionIds. has (h.decisionId))) throw Object. assign ( new Error ( 'Builder hold names a decision that is not in the current plan for this entity' ), { code: 'builder_hold_unknown_decision' });
175 return [ ...new Set (reasons)]. sort (compareKeys);
176 }
177 // Hold codes the toolkit has retired (target knowledge `retiredHoldCodes`): a shipped route now writes
178 // these records, so a builder that still holds one is stale generated code, never a decision. The
179 // inventory refuses it before import.
180 let retiredCache = null ;
181 function retiredHoldCodes () { return retiredCache ||= require ( '../resources/rp-target-wix/lib/domain-knowledge.js' ). retiredHoldCodes (); }
182 function retiredHolds ( body ) {
183 return (Array. isArray (body?.holds) ? body.holds : []). filter ( h => Object. hasOwn ( retiredHoldCodes (), h?.code)). map ( h => h.code);
184 }
185 // The entity's canonical decision IDs in the current exact plan. A plan without fieldContractVersion
186 // 1 has none, so it cannot hold a record.
187 function planDecisionIds ( plan , entity ) {
188 return new Set (plan?.fieldContractVersion === 1 ? ( planEntity (plan, entity)?.fieldMappings || []). map ( d => d.id). filter (Boolean) : []);
189 }
190 const HELD_START = entity => `<!-- read-back-held:${ entity }:start -->` , HELD_END = entity => `<!-- read-back-held:${ entity }:end -->` ;
191 // The held set the approver reviews: one section per entity in both mapping review files, rewritten
192 // by every inventory (removed when nothing is held). The inventory digest binds the same set.
193 function writeHeldReview ( projectDir , entity , held , inventorySha256 ) {
194 const start = HELD_START (entity), end = HELD_END (entity);
195 const block = held ? [start, `## Held records: ${ entity }` , '' ,
196 `${ held . count } ${ entity } records are held by the builder and not written. Each record and its reasons: \` execution/read-back-inventory/${ entity }.json \` ( \` records[].held \` , sha256 \` ${ inventorySha256 } \` ). Approval of this mapping covers this held set.` , '' ,
197 '| Decision:code | Records |' , '| --- | --- |' , ... Object. entries (held.reasons). map (([ r , n ]) => `| ${ r } | ${ n } |` ), end]. join ( ' \n ' ) : '' ;
198 for ( const name of [ 'mapping-summary.md' , 'mapping-plan.md' ]) {
199 const file = path. join (projectDir, 'mapping/review' , name);
200 let text = fs. existsSync (file) ? fs. readFileSync (file, 'utf8' ) : '' ;
201 if ( ! block && ! text. includes (start)) continue ;
202 if (text. includes (start) && text. includes (end)) text = text. slice ( 0 , text. indexOf (start)). trimEnd () + text. slice (text. indexOf (end) + end. length );
203 fs. mkdirSync (path. dirname (file), { recursive: true });
204 fs. writeFileSync ( `${ file }.writing` , `${ text . trimEnd () }${ block ? ` \n\n ${ block }` : ''} \n ` );
205 fs. renameSync ( `${ file }.writing` , file);
206 }
207 }
208 // The held set dependency readiness may treat as settled: the current inventory's
209 // per-record holds, valid against the current plan (the checks below), named by this inventory's
210 // sha256 in the reviewed held block, and bound -- inventory and plan digests -- by the current
211 // identified mapping approval. A missing inventory holds nothing; any other mismatch is not
212 // current, so no hold is honored.
213 async function approvedHeldRecords ( projectDir , entity , plan ) {
214 entityKey (entity);
215 const file = `execution/read-back-inventory/${ entity }.json` , notCurrent = { ok: false , reason: 'held-set-not-current' , held: new Map () };
216 let inv;
217 try { inv = JSON . parse (fs. readFileSync (path. join (projectDir, file), 'utf8' )); } catch (e) { return e.code === 'ENOENT' ? { ok: true , held: new Map () } : notCurrent; }
218 if ( ! inv || inv.entity !== entity || ! Array. isArray (inv.records) || inv.schemaVersion !== INVENTORY_SCHEMA_VERSION || inv.locationEncoding !== L . ENCODING ) return notCurrent;
219 if (inv.builder && ( ! inv.builder.path || hashArtifact (projectDir, inv.builder.path) !== inv.builder.sha256)) return notCurrent;
220 const ids = [ ... planDecisionIds (plan, entity)], held = new Map (), recount = Object. create ( null );
221 for ( const r of inv.records) if (r.held !== undefined ) {
222 if ( ! Array. isArray (r.held) || ! r.held. length || r.held. some ( reason => typeof reason !== 'string' || ! ids. some ( id => reason. startsWith ( `${ id }:` )))) return notCurrent;
223 held. set ( String (r.sourceId), [ ... r.held]);
224 for ( const reason of r.held) recount[reason] = (recount[reason] || 0 ) + 1 ;
225 }
226 if ( ! held.size) return inv.heldRecords ? notCurrent : { ok: true , held };
227 const summary = { count: held.size, reasons: Object. fromEntries (Object. keys (recount). sort (compareKeys). map ( r => [r, recount[r]])) };
228 if ( digest (summary) !== digest (inv.heldRecords ?? null )) return notCurrent;
229 const inventorySha256 = hashArtifact (projectDir, file);
230 let review = '' ;
231 try { review = fs. readFileSync (path. join (projectDir, 'mapping/review/mapping-summary.md' ), 'utf8' ); } catch {}
232 const start = review. indexOf ( HELD_START (entity)), end = review. indexOf ( HELD_END (entity));
233 if (start < 0 || end < start || ! review. slice (start, end). includes ( `sha256 \` ${ inventorySha256 } \` ` )) return notCurrent;
234 let approval = null , request = null ;
235 try { approval = ( await state. readJson (projectDir, 'orchestration/approvals.json' , {})).mapping; } catch { return notCurrent; }
236 if ( await oneClickHeldApproval (projectDir, approval, file, inv)) return { ok: true , held, inventorySha256 };
237 try { request = await require ( './user-actions' ). readUserAction (projectDir, approval?.requestId || '' ); } catch { return notCurrent; }
238 if ( ! validApproval (approval, { [file]: inventorySha256, 'mapping/mapping-plan.json' : hashArtifact (projectDir, 'mapping/mapping-plan.json' ) }, request)) return notCurrent;
239 return { ok: true , held, inventorySha256 };
240 }
241 // Human-approved policy (2026-11-01): in a run the user chose as 1-click (automationMode one_click,
242 // source user), the agent-decided mapping approval counts for the held set; 1-click asks no
243 // mid-run questions, so it has no identified request. It binds the held set through what it pins
244 // instead: it must name this inventory and the plan, its census reference must be current (that
245 // reconciliation pins the mapping-plan digest), the inventory must pin its builder (checked above)
246 // and its recorded extract must be unchanged. The held summary and reviewed held block were
247 // checked above. Normal mode and an inferred one_click still need the identified user approval.
248 // Omission approvals are unaffected.
249 async function oneClickHeldApproval ( projectDir , approval , file , inv ) {
250 try {
251 if (approval?.status !== 'approved' || approval.decidedBy !== 'agent' ) return false ;
252 if ( ! require ( './orchestration-decisions' ). isExplicitUserOneClick ( await state. readJson (projectDir, 'orchestration/decisions.json' , {}) || {})) return false ;
253 const refs = (approval.artifactRefs || []). map ( r => typeof r === 'string' ? r : r?.path);
254 if ( ! refs. includes (file) || ! refs. includes ( 'mapping/mapping-plan.json' ) || ! inv.builder || ! inv.extract?.path) return false ;
255 if ( await require ( './extract-ledger' ). fileDigest (projectDir, inv.extract.path) !== inv.extract.sha256) return false ;
256 return await require ( './census-reconciliation' ). mappingApprovalBound (projectDir, approval);
257 } catch { return false ; }
258 }
259 const idsDigestOf = value => require ( './mapping-field-contract' ). digest (value);
260 const idsDigest = ids => require ( './mapping-field-contract' ). digest ([ ...new Set (ids)]. sort ());
261 // The generated reader feeds its existing extract stream once. Values are not written to the inventory.
262 async function inventory ( projectDir , entity , records , {
263 sourceKey = r => r.id,
264 buildWrite = r => r,
265 rows = [],
266 templates = [],
267 builder = null ,
268 extract = null
269 } = {}) {
270 entityKey (entity);
271 assertApplicabilityShape (rows);
272 const declared = applicabilityKey (rows), applicability = declared. length ? Object. create ( null ) : null ;
273 for ( const r of rows) if (applicability && r.appliesWhen !== undefined ) applicability[r.canonicalField] = { writePath: canonical (r.writePath), applicable: 0 , notApplicable: 0 , missing: 0 , unexpected: 0 };
274 const preparationStarted = performance. now ();
275 const decisionIds = planDecisionIds ( await state. readJson (projectDir, 'mapping/mapping-plan.json' , {}), entity);
276 const sourcePaths = Object. create ( null ),
277 writePaths = Object. create ( null ),
278 members = new Map (),
279 perRecord = [],
280 heldReasons = Object. create ( null ),
281 retired = new Map (),
282 seen = new Set ();
283 let heldCount = 0 ;
284 for await ( const record of records) {
285 const id = sourceKey (record);
286 if (id === undefined || id === null || String (id) === '' || seen. has ( String (id))) throw new Error ( 'Read-back inventory requires unique stable source keys' );
287 seen. add ( String (id));
288 const body = await buildWrite (record),
289 source = leafPaths (record, { templates }),
290 held = builderHold (body, decisionIds);
291 for ( const p of source) {
292 sourcePaths[p] = (sourcePaths[p] || 0 ) + 1 ;
293 if ( ! members. has (p)) members. set (p, []);
294 members. get (p). push ( String (id));
295 }
296 // A held record stays in source coverage but is never written: it is outside output
297 // applicability and the canary universe, and is listed with its reasons.
298 if (held) for ( const code of retiredHolds (body)) retired. set (code, [ ... (retired. get (code) || []), String (id)]);
299 if (held) {
300 heldCount += 1 ;
301 for ( const r of held) heldReasons[r] = (heldReasons[r] || 0 ) + 1 ;
302 perRecord. push ({ sourceId: String (id), writePaths: [], held });
303 continue ;
304 }
305 const sent = leafPaths (body);
306 // A non-applicable output is outside this record's coverage unless the builder emits it
307 // (then the gate refuses that canary, and validation reports it first).
308 const judged = applicability ? outputApplicability (rows, record, body) : null ;
309 const applies = row => ! judged || judged.states. find ( s => s.row === row)?.applies !== false ;
310 const fields = [ ...new Set (rows. filter ( row => applies (row) && (row.sourcePaths || []). some ( p => valuesAt (record, p). some (populated)) || sent. has ( canonical (row.writePath))). map ( row => canonical (row.writePath)))]. sort (compareKeys);
311 if (judged) for ( const s of judged.states) {
312 const count = applicability[s.row.canonicalField];
313 if ( ! count) continue ;
314 count[s.applies ? 'applicable' : 'notApplicable' ] += 1 ;
315 if (judged.missing. includes (s.row)) count.missing += 1 ;
316 if (judged.unexpected. includes (s.row)) count.unexpected += 1 ;
317 }
318 for ( const p of fields) writePaths[p] = (writePaths[p] || 0 ) + 1 ;
319 perRecord. push ({
320 sourceId: String (id),
321 writePaths: fields
322 });
323 }
324 // Refused before anything is written: the inventory file and held review stay as they were.
325 if (retired.size) {
326 const detail = [ ... retired]. map (([ code , ids ]) => `${ code } (${ ids . length }: ${ ids . slice ( 0 , 20 ). join ( ', ' ) }${ ids . length > 20 ? ', ...' : ''}). ${ retiredHoldCodes ()[ code ]. remediation }` ). join ( ' ' );
327 throw Object. assign ( new Error ( `Builder holds a retired hold code for ${ entity }: ${ detail }` ), { code: 'builder_hold_retired' , retired: Object. fromEntries (retired), remediation: Object. fromEntries ([ ... retired. keys ()]. map ( code => [code, retiredHoldCodes ()[code].remediation])) });
328 }
329 perRecord. sort (( a , b ) => compareKeys (a.sourceId, b.sourceId));
330 // Per-location complete membership digest: lets omission review bind each location's
331 // measured record set without writing source values or a second source scan.
332 const sourceIdsDigests = Object. create ( null );
333 for ( const p of Object. keys (sourcePaths). sort (compareKeys)) sourceIdsDigests[p] = idsDigest (members. get (p));
334 const result = {
335 schemaVersion: INVENTORY_SCHEMA_VERSION ,
336 locationEncoding: L . ENCODING ,
337 entity,
338 selectorTemplates: templateKey (templates),
339 builder,
340 ... (extract ? { extract } : {}),
341 sourcePaths,
342 sourceIdsDigests,
343 writePaths,
344 records: perRecord,
345 // Present only when a row declares appliesWhen, so an inventory without one is unchanged.
346 ... (applicability ? { applicabilityDigest: idsDigestOf (declared), outputApplicability: applicability } : {}),
347 ... (heldCount ? { heldRecords: { count: heldCount, reasons: Object. fromEntries (Object. keys (heldReasons). sort (compareKeys). map ( r => [r, heldReasons[r]])) } } : {})
348 };
349 await state. atomic (projectDir, `execution/read-back-inventory/${ entity }.json` , result);
350 writeHeldReview (projectDir, entity, result.heldRecords, hashArtifact (projectDir, `execution/read-back-inventory/${ entity }.json` ));
351 Object. defineProperty (result, 'preparationMs' , {
352 value: performance. now () - preparationStarted
353 });
354 return result;
355 }
356 // `exclude`: records that cannot be canaries now (their own parent record is final for this run,
357 // so the gate would only defer them); they are never selected and are dropped from `existing`.
358 function cover ( inv , {
359 ceiling = 10 ,
360 existing = [],
361 exclude = []
362 } = {}) {
363 if ( ! Number. isInteger (ceiling) || ceiling < 10 || ceiling > 30 ) throw new Error ( 'Canary ceiling must be a recorded user integer from 10 to 30' );
364 const records = [ ... inv.records]. sort (( a , b ) => compareKeys (a.sourceId, b.sourceId));
365 const universe = Object. keys (inv.writePaths). sort (compareKeys),
366 left = new Set (universe),
367 selected = [],
368 coveredBy = {};
369 const add = r => {
370 selected. push (r.sourceId);
371 for ( const p of r.writePaths) if (left. delete (p)) coveredBy[p] = r.sourceId;
372 };
373 for ( const id of existing. filter ( id => ! exclude. includes (id))) {
374 const row = records. find ( r => r.sourceId === id);
375 if ( ! row) throw new Error ( 'A previously written canary is missing from the inventory' );
376 add (row);
377 }
378 while (left.size) {
379 const candidates = records. filter ( r => ! selected. includes (r.sourceId) && ! exclude. includes (r.sourceId)). map ( r => ({
380 row: r,
381 gain: r.writePaths. filter ( p => left. has (p)). length
382 })). sort (( a , b ) => b.gain - a.gain || compareKeys (a.row.sourceId, b.row.sourceId));
383 if ( ! candidates[ 0 ]?.gain) break ;
384 add (candidates[ 0 ].row);
385 }
386 return {
387 universeDigest: digest ({
388 universe,
389 records
390 }),
391 ceiling,
392 canarySourceIds: selected,
393 coveredBy,
394 requiredCount: selected. length ,
395 uncoveredPaths: selected. length > ceiling ? universe. filter ( p => ! selected. slice ( 0 , ceiling). some ( id => records. find ( r => r.sourceId === id).writePaths. includes (p))) : [ ... left],
396 status: selected. length > ceiling || left.size ? 'coverage-exceeds-ceiling' : 'covered'
397 };
398 }
399 function mappingRows ( plan , entity ) {
400 const raw = plan.entityMappings || plan.entityDecisions || plan.mappings || plan.entities || [];
401 const entities = Array. isArray (raw) ? raw : Object. entries (raw). map (([ key , v ]) => ({
402 sourceEntity: key,
403 ... v
404 }));
405 const fields = require ( './mapping-field-contract' );
406 const entry = entities. find ( e => fields. entityKeyMatches (e, entity));
407 const rows = entry?.fieldMappings || entry?.fields || entry?.mappingRows || [];
408 if (plan.fieldContractVersion !== 1 ) return rows;
409 // Derive the approved verification projection from canonical outputs. Never
410 // trust a separately authored flat table or infer a remote read path.
411 return entry ? require ( './mapping-field-contract' ). projectEntity (entry).verificationMappings || [] : [];
412 }
413 function validApproval ( approval , current , request ) {
414 return approval?.status === 'approved' && approval.decidedBy === 'user' && GUID . test (approval.approvedBy?.userId || '' ) && Boolean (approval.approvedBy?.displayName?. trim ()) && approval.requestId === request?.requestId && [ 'pending' , 'answered' ]. includes (request?.status) && Object. entries (current). every (([ p , h ]) => approval.bindingDigests?.[p] === h && request.artifactRefs?. some ( r => r.path === p && String (r.sha256). replace ( / ^ sha256:/ , '' ) === h));
415 }
416 async function validate ( projectDir , entity , {
417 decision ,
418 plan ,
419 inv ,
420 approval ,
421 request
422 } = {}) {
423 entityKey (entity);
424 plan ||= await state. readJson (projectDir, 'mapping/mapping-plan.json' );
425 const decisionFile = decisionFileName (projectDir, entity);
426 decision ||= await state. readJson (projectDir, `mapping/entity-decisions/${ decisionFile }` );
427 inv ||= await state. readJson (projectDir, `execution/read-back-inventory/${ entity }.json` );
428 approval ||= ( await state. readJson (projectDir, 'orchestration/approvals.json' , {})).mapping;
429 if ( ! request && approval?.requestId) {
430 try {
431 request = await require ( './user-actions' ). readUserAction (projectDir, approval.requestId);
432 } catch { /* invalid approval */ }
433 }
434 const errors = [],
435 contract = domainContract (entity),
436 copied = decision.readBackContract;
437 if ( ! copied || copied.domainDigest !== contract.domainDigest || digest (copied.intendedFields) !== digest (contract.intendedFields) || digest (copied.readBackProjection) !== digest (contract.readBackProjection)) errors. push ( 'read-back domain contract stale or missing' );
438 if ( ! inv || inv.entity !== entity || ! Array. isArray (inv.records)) errors. push ( 'read-back inventory missing; check not run' );
439 // Fail closed on an inventory written under another location encoding or selector set,
440 // or by a builder that has since changed: its paths/counts no longer mean the same thing.
441 else if (inv.schemaVersion !== INVENTORY_SCHEMA_VERSION || inv.locationEncoding !== L . ENCODING ) errors. push ( 'read-back inventory stale (location encoding); rerun inventory' );
442 else {
443 let templates = null ;
444 try { templates = templateKey ( selectorTemplates (plan, entity)); } catch (e) { errors. push (e.message); }
445 if (templates && digest (templates) !== digest (inv.selectorTemplates || [])) errors. push ( 'read-back inventory stale (selector templates changed); rerun inventory' );
446 if (inv.builder && ( ! inv.builder.path || hashArtifact (projectDir, inv.builder.path) !== inv.builder.sha256)) errors. push ( 'read-back inventory stale (builder changed or missing); rerun inventory' );
447 // The extract the inventory measured must still be the toolkit-recorded version, with no lost fields.
448 // The CLI inventory (it pins a builder) always records its extract; one without is not trusted.
449 if (inv.builder && ! inv.extract) errors. push ( 'read-back inventory has no recorded source extract; rerun inventory' );
450 // The inventory describes one extract version; a different file needs a fresh inventory, which
451 // re-reads and records it (repairing a changed or stripped extract without blocking).
452 if (inv.extract) {
453 const current = await require ( './extract-ledger' ). fileDigest (projectDir, inv.extract.path). catch (() => null );
454 if (current !== inv.extract.sha256) errors. push ( `source_extract_changed_since_inventory:${ inv . extract . path }: rerun inventory` );
455 }
456 }
457 const loc = p => {
458 try { return canonical (p); } catch { errors. push ( `Unparseable location: ${ JSON . stringify ( p ) }` ); return null ; }
459 };
460 const rows = mappingRows (plan, entity). map ( r => ({ ... r, sourcePaths: Array. isArray (r.sourcePaths) ? r.sourcePaths. map (loc) : r.sourcePaths })),
461 dispositions = decision.fieldDispositions || [];
462 // Output applicability: a closed predicate over the decision's own locations,
463 // bound to the inventory it was measured under, and every record honored by the builder.
464 const declaredRows = rows. filter ( r => r.appliesWhen !== undefined );
465 if (declaredRows. length ) {
466 const A = require ( './output-applicability' ), fields = require ( './mapping-field-contract' ), entry = planEntity (plan, entity);
467 for ( const r of declaredRows) {
468 const d = plan.fieldContractVersion === 1 ? (entry?.fieldMappings || []). find ( x => x.id === r.decisionRef) : null ;
469 let own = [];
470 try { own = d ? fields. decisionLocations (d) : []; } catch { /* reported by the mapping contract */ }
471 if ( ! d) errors. push ( `Invalid output applicability ${ r . canonicalField || r . id }: appliesWhen requires a fieldContractVersion 1 decision` );
472 else for ( const m of A . validate (r.appliesWhen, own)) errors. push ( `Invalid output applicability ${ r . canonicalField }: ${ m }` );
473 }
474 }
475 if (inv && Array. isArray (inv.records) && (declaredRows. length || inv.applicabilityDigest !== undefined )) {
476 if (inv.applicabilityDigest !== idsDigestOf ( applicabilityKey (rows))) errors. push ( 'read-back inventory stale (output applicability changed); rerun inventory' );
477 else for ( const [ field , c ] of Object. entries (inv.outputApplicability || {}). sort (([ a ], [ b ]) => compareKeys (a, b))) {
478 if (c.missing) errors. push ( `Builder omits an applicable output: ${ field } at ${ c . writePath } (${ c . missing } records)` );
479 if (c.unexpected) errors. push ( `Builder emits a non-applicable output: ${ field } at ${ c . writePath } (${ c . unexpected } records)` );
480 }
481 }
482 // Held records (0213 A.2.2): each hold names a decision of this entity in the current plan, and
483 // the summary matches the per-record reasons the approval binds.
484 let heldRecords = null ;
485 if (inv && Array. isArray (inv.records)) {
486 const ids = planDecisionIds (plan, entity), recount = Object. create ( null ), unknown = new Map ();
487 let count = 0 ;
488 for ( const r of inv.records) if (r.held !== undefined ) {
489 count += 1 ;
490 for ( const reason of Array. isArray (r.held) ? r.held : [ null ]) {
491 recount[reason] = (recount[reason] || 0 ) + 1 ;
492 if ( typeof reason !== 'string' || ! [ ... ids]. some ( id => reason. startsWith ( `${ id }:` ))) {
493 const id = typeof reason === 'string' ? reason. split ( ':' )[ 0 ] : String (reason);
494 unknown. set (id, (unknown. get (id) || 0 ) + 1 );
495 }
496 }
497 }
498 for ( const [ id , n ] of [ ... unknown]. sort (([ a ], [ b ]) => compareKeys (a, b))) errors. push ( `Builder hold names an unknown ${ entity } decision: ${ id } (${ n } records); fix the builder, rerun inventory` );
499 const summary = count ? { count, reasons: Object. fromEntries (Object. keys (recount). sort (compareKeys). map ( r => [r, recount[r]])) } : undefined ;
500 if ( digest (summary ?? null ) !== digest (inv.heldRecords ?? null )) errors. push ( 'read-back inventory held summary does not match its records; rerun inventory' );
501 if (count) heldRecords = { ... summary, sourceIds: `execution/read-back-inventory/${ entity }.json records[].held` };
502 }
503 const gate = await state. load (projectDir),
504 history = [ ... gate.observedLosses, ... Object. values (gate.runs). flatMap ( run => Object. values (run.entities || {}). flatMap ( e => [ ... (e.attemptedPaths || []), ... (e.settlements || []). filter ( s => s.status !== 'passed' )]))];
505 const inventoried = Object. keys (inv?.sourcePaths || {});
506 const requirements = [ ... inventoried. map ( p => ({
507 kind: 'source' ,
508 path: loc (p)
509 })), ... (contract.intendedFields || []). filter ( f => f.sourcePaths. some ( p => inventoried. some ( k => L . isPrefix (p, k)))). map ( f => ({
510 kind: 'intended' ,
511 path: loc (f.targetPath),
512 requiredSubpaths: f.requiredSubpaths || []
513 }))]. filter ( r => r.path !== null );
514 // An intended item list ([]) is covered by rows writing the list itself or fields inside each item.
515 const coversIntended = ( writePath , p ) => L . within (p, writePath) && (p. endsWith ( '[]' ) || L . within (writePath, p));
516 const samePath = ( x , p ) => L . within (p, x) && L . within (x, p);
517 const historyHas = ( h , p ) => [ ... (h.sourcePaths || []), h.writePath, h.intendedTargetPath]. some ( x => {
518 try { return x !== undefined && x !== null && canonical (x) === p; } catch { return x === p; }
519 });
520 let current;
521 try {
522 current = bindings (projectDir, entity);
523 } catch (e) {
524 errors. push (e.message);
525 }
526 const omissionMode = require ( './mapping-omission-approval' ). mode ( await state. readJson (projectDir, 'orchestration/decisions.json' ,{}));
527 for ( const requirement of requirements) {
528 const matches = dispositions. filter ( d => {
529 try { return canonical (d.path || d.sourcePath || d.targetPath) === requirement.path && ( ! d.kind || d.kind === requirement.kind); } catch { return false ; }
530 });
531 // No disposition for the intended item list itself: the item-field dispositions inside it
532 // answer for it together, all from one write operation and at most one per item field.
533 const inside = requirement.kind === 'intended' && ! matches. length ? dispositions. filter ( d => d.kind === 'intended' && coversIntended (d.path || d.targetPath, requirement.path)) : [];
534 if ( ! inside. length && matches. length !== 1 ) {
535 errors. push ( `Disposition missing or duplicate: ${ requirement . kind }:${ requirement . path }` );
536 continue ;
537 }
538 if (inside. length ) {
539 if ( new Set (inside. map ( d => d.writeOperation)).size > 1 ) errors. push ( `Ambiguous intended item fields: ${ requirement . path } (more than one write operation)` );
540 const seen = new Set ();
541 for ( const d of inside) {
542 const p = canonical (d.path || d.targetPath);
543 if (seen. has (p)) errors. push ( `Disposition missing or duplicate: intended:${ p }` );
544 seen. add (p);
545 }
546 }
547 const covering = [];
548 for ( const d of inside. length ? inside : matches) {
549 if (d.status === 'mapped' ) {
550 const refs = plan.fieldContractVersion === 1 ? d.mappingRefs : [d.mappingRef];
551 const mapped = Array. isArray (refs) ? refs. map ( ref => ref === undefined || ref === null ? null : rows. find (( r , i ) =>
552 (plan.fieldContractVersion === 1 ? r.canonicalField === ref : r.id !== undefined && r.id === ref) || plan.fieldContractVersion !== 1 &&
553 (r.mappingRef !== undefined && r.mappingRef === ref || String (i) === String (ref)))) : [];
554 if ( ! mapped. length || mapped. some ( row => ! row ||
555 (requirement.kind === 'source' ? ! row.sourcePaths?. includes (requirement.path) : ! coversIntended (row.writePath, requirement.path) || plan.fieldContractVersion === 1 && row.writeOperation !== d.writeOperation) ||
556 ! row.readPath && ! row.unverifiableReason)) errors. push ( `Mapping does not cover ${ requirement . path }` );
557 else covering. push ( ... mapped);
558 } else if (d.status === 'deferred' ) {
559 if ( ! require ( './mapping-recovery' ). permitsDisposition (projectDir, plan, planEntity (plan, entity), d)) errors. push ( `Invalid recoverable deferral: ${ requirement . path }` );
560 if (history. some ( h => ( ! h.entity || h.entity === entity) && historyHas (h, requirement.path))) errors. push ( `Attempted or observed-loss path cannot become a deferred field: ${ requirement . path }` );
561 } else if (d.status === 'notMigrated' ) {
562 if (history. some ( h => ( ! h.entity || h.entity === entity) && historyHas (h, requirement.path))) errors. push ( `Attempted or observed-loss path cannot be omitted: ${ requirement . path }` );
563 if ( ! current || ! validApproval (approval, current, request) || omissionMode === 'one_click' || approval?.omissionApprovalContext?.automationMode !== 'interactive' || request?.omissionApprovalContext?.automationMode !== 'interactive' ) errors. push ( `Identified exact-binding approval missing: ${ requirement . path }` );
564 } else errors. push ( `Unknown disposition: ${ requirement . path }` );
565 }
566 // The item fields the target contract requires (product images: url). Answered field by field,
567 // each needs its own disposition, and must be written whenever any field of the item is.
568 for ( const sub of requirement.requiredSubpaths || []) {
569 const need = canonical ( `${ requirement . path }.${ sub }` );
570 if (inside. length ) {
571 const own = inside. filter ( d => samePath (d.path || d.targetPath, need));
572 if ( ! own. length ) errors. push ( `Disposition missing or duplicate: intended:${ need }` );
573 else if (inside. some ( d => d.status === 'mapped' ) && own[ 0 ].status !== 'mapped' ) errors. push ( `Mapping does not cover ${ need }` );
574 } else if (covering. length && ! covering. some ( row => L . within (row.writePath, need) || L . within (need, row.writePath))) errors. push ( `Mapping does not cover ${ need }` );
575 }
576 }
577 if (plan.fieldContractVersion === 1 ) {
578 const writes = new Map ();
579 for ( const row of rows) {
580 const key = canonical (row.writePath), binding = JSON . stringify ([row.targetRef,row.writeOperation,row.writeInput]);
581 if (writes. has (key) && writes. get (key) !== binding) errors. push ( `Ambiguous read-back operation: ${ key }` );
582 writes. set (key,binding);
583 }
584 }
585 if (plan.fieldContractVersion === 1 && require ( './mapping-omission-approval' ). candidates (plan). length ) {
586 try { require ( './mapping-omission-approval' ). assertAuthorized (projectDir,plan);} catch (e) {errors. push (e.message);}
587 }
588 const serverOwned = contract.readBackProjection?.serverOwned || [];
589 for ( const f of contract.intendedFields || []) if (serverOwned. some ( p => ( typeof p === 'string' ? p : p.path) === f.targetPath)) errors. push ( `Intended field cannot be server-owned: ${ f . targetPath }` );
590 for ( const r of rows) if ( ! r.sourcePaths?. length || ! r.writePath || ! r.readPath && ! r.unverifiableReason) errors. push ( 'Mapping row missing read-back declaration' );
591 return {
592 valid: ! errors. length ,
593 fieldContractVersion:plan.fieldContractVersion,
594 errors,
595 contract,
596 rows,
597 inventory: inv,
598 decision,
599 approval,
600 bindingDigests: current,
601 heldRecords
602 };
603 }
604 module . exports = {
605 DOMAINS,
606 retiredHoldCodes,
607 approvedHeldRecords,
608 entityKey,
609 canonical,
610 digest,
611 populated,
612 compareKeys,
613 domainPath,
614 domainContract,
615 bindingPaths,
616 decisionFileName,
617 bindings,
618 leafPaths,
619 valuesAt,
620 selectorTemplates,
621 draftSelectorDescendantStubs,
622 assertPureBuilder,
623 templateKey,
624 applicabilityKey,
625 outputApplicability,
626 builderHold,
627 planDecisionIds,
628 INVENTORY_SCHEMA_VERSION,
629 inventory,
630 cover,
631 mappingRows,
632 validApproval,
633 validate
634 };