@smartmemory/compose 0.4.0 → 0.5.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.
- package/.claude/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +1 -1
- package/bin/compose.js +33 -14
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build-cancel.js +205 -0
- package/lib/build.js +552 -87
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +24 -1
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +28 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/package.json +7 -5
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
package/server/maya-routes.js
CHANGED
|
@@ -44,7 +44,9 @@ import {
|
|
|
44
44
|
MayaWorkspaceCollisionError,
|
|
45
45
|
} from '../lib/maya-identity.js';
|
|
46
46
|
import { ideaboxContext } from '../lib/fluid/ideabox-ops.js';
|
|
47
|
-
import { composeColleagueContext } from '../lib/colleague/context.js';
|
|
47
|
+
import { composeColleagueContext, composePortfolioContext, toMayaContext } from '../lib/colleague/context.js';
|
|
48
|
+
import { openPortfolio, recallAcrossPortfolio, assertMemberWorkspacesDistinct } from '../lib/fluid/portfolio.js';
|
|
49
|
+
import { parsePortfolioConfig } from '../lib/fluid/factory.js';
|
|
48
50
|
import { writebackReply } from '../lib/colleague/writeback.js';
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -67,11 +69,51 @@ const CAPABILITIES = Object.freeze({
|
|
|
67
69
|
// `author:'maya'` plus the embedded msg marker. `ui:ideabox` is the cockpit
|
|
68
70
|
// door these contexts genuinely come through; a colleague-specific origin
|
|
69
71
|
// joins the contract enum only when the panel gains a record-creating op.
|
|
70
|
-
async function defaultComposeContext(root, { focusId }) {
|
|
72
|
+
async function defaultComposeContext(root, { focusId, scope, text }) {
|
|
73
|
+
if (scope === 'portfolio') {
|
|
74
|
+
// The declaring root's ideabox context is NOT built here. A portfolio turn
|
|
75
|
+
// reads through the members' own providers, so constructing this first made
|
|
76
|
+
// a failure in the declaring root's provider abort the turn before a single
|
|
77
|
+
// member was asked — the one product being broken silencing the other N,
|
|
78
|
+
// which is the whole failure mode this feature exists to avoid.
|
|
79
|
+
// The portfolio path is a separate composer, not a widened one: the
|
|
80
|
+
// project-scoped turn must stay byte-identical so a portfolio bug can never
|
|
81
|
+
// degrade the ordinary one.
|
|
82
|
+
const portfolio = await openPortfolio(root);
|
|
83
|
+
return composePortfolioContext({ text }, { portfolio, recallAcross: recallAcrossPortfolio });
|
|
84
|
+
}
|
|
71
85
|
const ctx = await ideaboxContext(root, { origin: 'ui:ideabox' });
|
|
72
86
|
return composeColleagueContext(ctx, { focusId });
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Is a portfolio declared here at all, and is it valid?
|
|
91
|
+
*
|
|
92
|
+
* Read through the AUTHORITATIVE validating parser, never the lenient
|
|
93
|
+
* `maya-config` reader: a portfolio with a typo in it must refuse, not come back
|
|
94
|
+
* as "none declared" and quietly answer for one product.
|
|
95
|
+
*/
|
|
96
|
+
function portfolioDeclared(root) {
|
|
97
|
+
try {
|
|
98
|
+
return parsePortfolioConfig(root) !== null;
|
|
99
|
+
} catch {
|
|
100
|
+
// Invalid is not absent. Let the caller name the real reason.
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The exact reason, so the funnel points at the setting that is actually wrong. */
|
|
106
|
+
function portfolioMisconfigReason(root) {
|
|
107
|
+
try {
|
|
108
|
+
if (parsePortfolioConfig(root) === null) {
|
|
109
|
+
return 'this project declares no fluid.portfolio, so a portfolio turn has no members to ask';
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return `fluid.portfolio is declared but invalid — ${shortReason(e)}`;
|
|
113
|
+
}
|
|
114
|
+
return 'fluid.portfolio is declared but unusable';
|
|
115
|
+
}
|
|
116
|
+
|
|
75
117
|
/** The real write-back (S4): reconcile-then-append through the shared ops
|
|
76
118
|
* module. Returns an OUTCOME, never throws (lib/colleague/writeback.js). */
|
|
77
119
|
async function defaultPerformWriteback(root, args) {
|
|
@@ -168,6 +210,54 @@ export function attachMayaRoutes(app, {
|
|
|
168
210
|
}
|
|
169
211
|
const focusId = req.body?.focusId ? String(req.body.focusId) : null;
|
|
170
212
|
|
|
213
|
+
// A CLOSED enum. Without this a typo ('portoflio') falls through to a
|
|
214
|
+
// project-scoped answer that looks exactly like a correct one — the silent
|
|
215
|
+
// downgrade this feature exists to refuse, arriving through the door left
|
|
216
|
+
// open by not checking.
|
|
217
|
+
// Validated as the RAW value. Coercing first made `null` look like "absent"
|
|
218
|
+
// (silently selecting project scope) and turned `["portfolio"]` into the
|
|
219
|
+
// string "portfolio" — both of which are the silent widening/downgrade this
|
|
220
|
+
// enum exists to refuse, arriving through the coercion rather than the check.
|
|
221
|
+
const rawScope = req.body?.scope;
|
|
222
|
+
const turnScope = rawScope === undefined ? undefined : rawScope;
|
|
223
|
+
if (turnScope !== undefined && turnScope !== 'project' && turnScope !== 'portfolio') {
|
|
224
|
+
return {
|
|
225
|
+
errorBody: {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: {
|
|
228
|
+
kind: 'invalid',
|
|
229
|
+
message: `unknown scope ${JSON.stringify(turnScope)} — expected "project" or "portfolio"`,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (turnScope === 'portfolio' && focusId) {
|
|
235
|
+
return {
|
|
236
|
+
errorBody: {
|
|
237
|
+
ok: false,
|
|
238
|
+
error: {
|
|
239
|
+
kind: 'invalid',
|
|
240
|
+
message: 'a portfolio turn spans products and cannot also be focused on one idea',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (turnScope === 'portfolio' && !portfolioDeclared(root)) {
|
|
247
|
+
// Named, never a silent downgrade. Without its own branch this surfaces as
|
|
248
|
+
// the generic `context` funnel, which tells the user nothing about
|
|
249
|
+
// membership — and a portfolio question answered for one product looks
|
|
250
|
+
// exactly like a correct answer.
|
|
251
|
+
return {
|
|
252
|
+
errorBody: {
|
|
253
|
+
ok: false,
|
|
254
|
+
error: {
|
|
255
|
+
kind: 'misconfigured',
|
|
256
|
+
message: portfolioMisconfigReason(root),
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
171
261
|
try {
|
|
172
262
|
if (!hasSmartmemoryFluidProvider(root)) {
|
|
173
263
|
return { errorBody: { ok: false, error: { kind: 'connect-smartmemory' } } };
|
|
@@ -204,11 +294,25 @@ export function attachMayaRoutes(app, {
|
|
|
204
294
|
await ensureIdentity(root, { smBaseUrl: getSmartmemoryConfig(root)?.baseUrl, mode });
|
|
205
295
|
}
|
|
206
296
|
|
|
297
|
+
// BEFORE composition, not after. Composition is what fans out to the
|
|
298
|
+
// members, so checking afterwards let a member configured at Maya's own
|
|
299
|
+
// workspace be READ and only then refused — the guard reported a refusal
|
|
300
|
+
// for an access that had already happened, which is not a guard.
|
|
301
|
+
if (turnScope === 'portfolio') {
|
|
302
|
+
try {
|
|
303
|
+
assertMemberWorkspacesDistinct(root, workspaceClaimOf(identity));
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return {
|
|
306
|
+
errorBody: { ok: false, error: { kind: 'workspace-collision', message: e.message } },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
207
311
|
// Context composition is load-bearing: a failure here is a funnel, never
|
|
208
312
|
// a silent fall-through to plain chat (COLLEAGUE-ALL-IN).
|
|
209
313
|
let context;
|
|
210
314
|
try {
|
|
211
|
-
context = await composeContext(root, { focusId });
|
|
315
|
+
context = await composeContext(root, { focusId, scope: turnScope, text });
|
|
212
316
|
} catch (e) {
|
|
213
317
|
return {
|
|
214
318
|
errorBody: {
|
|
@@ -315,7 +419,7 @@ export function attachMayaRoutes(app, {
|
|
|
315
419
|
|
|
316
420
|
if (req.query?.stream !== '1') {
|
|
317
421
|
try {
|
|
318
|
-
const reply = await client.chat({ message: text, channelContext: context.blocks });
|
|
422
|
+
const reply = await client.chat({ message: text, channelContext: toMayaContext(context.blocks) });
|
|
319
423
|
|
|
320
424
|
// Write-back (S4): the chat result is AUTHORITATIVE — her reply renders
|
|
321
425
|
// whatever happens here, and a write-back failure is an outcome field,
|
|
@@ -332,7 +436,7 @@ export function attachMayaRoutes(app, {
|
|
|
332
436
|
memory_available: reply.memory_available ?? null,
|
|
333
437
|
writeback,
|
|
334
438
|
context: {
|
|
335
|
-
sent: context.blocks.map((b) => b.author),
|
|
439
|
+
sent: [...new Set(context.blocks.map((b) => b.author))],
|
|
336
440
|
omissions: context.omissions,
|
|
337
441
|
// The composed blocks themselves — the panel's findings accordion
|
|
338
442
|
// renders these (design §4); authors carry the provenance labels.
|
|
@@ -382,7 +486,7 @@ export function attachMayaRoutes(app, {
|
|
|
382
486
|
try {
|
|
383
487
|
const reply = await client.chatStream({
|
|
384
488
|
message: text,
|
|
385
|
-
channelContext: context.blocks,
|
|
489
|
+
channelContext: toMayaContext(context.blocks),
|
|
386
490
|
onToken: (token) => {
|
|
387
491
|
openStream();
|
|
388
492
|
writeEvent('token', { text: token });
|
|
@@ -395,7 +499,7 @@ export function attachMayaRoutes(app, {
|
|
|
395
499
|
message_id: reply.message_id,
|
|
396
500
|
memory_available: reply.memory_available ?? null,
|
|
397
501
|
context: {
|
|
398
|
-
sent: context.blocks.map((b) => b.author),
|
|
502
|
+
sent: [...new Set(context.blocks.map((b) => b.author))],
|
|
399
503
|
omissions: context.omissions,
|
|
400
504
|
blocks: context.blocks,
|
|
401
505
|
},
|
package/server/mcp-tool-defs.js
CHANGED
|
@@ -527,31 +527,6 @@ export const TOOLS = [
|
|
|
527
527
|
},
|
|
528
528
|
},
|
|
529
529
|
},
|
|
530
|
-
// -------------------------------------------------------------------------
|
|
531
|
-
// Canon override — COMP-CANON-OVERRIDE (COMP-CANON-GUARD Decision 4)
|
|
532
|
-
// -------------------------------------------------------------------------
|
|
533
|
-
{
|
|
534
|
-
name: 'canon_override_grant',
|
|
535
|
-
effect: 'mutating',
|
|
536
|
-
writes: ["override-ledger", "override-attest", "override-grants"],
|
|
537
|
-
description:
|
|
538
|
-
'Mint a single-use, path-scoped grant permitting ONE direct write to a guarded canon path. '
|
|
539
|
-
+ 'The bypass row is appended to .compose/canon-overrides.jsonl BEFORE the grant exists, so a grant '
|
|
540
|
-
+ 'cannot be unrecorded. The token expires in 5 minutes and is burned by the first write. '
|
|
541
|
-
+ 'Governance state (the bypass ledger, its baseline, the grant directory) is deliberately NOT grantable. '
|
|
542
|
-
+ 'SCOPE: this is audit and careless-drift tooling for the Claude Write/Edit path — it is not enforcement. '
|
|
543
|
-
+ 'Bash and Codex writes never reach the guard, and `operation` is a declared label recorded for later '
|
|
544
|
-
+ 'analysis, never verified against the write that follows.',
|
|
545
|
-
inputSchema: {
|
|
546
|
-
type: 'object',
|
|
547
|
-
required: ['path', 'reason'],
|
|
548
|
-
properties: {
|
|
549
|
-
path: { type: 'string', description: 'Repo-relative path to grant one write for. Must be guarded at the write-time hook and override-eligible.' },
|
|
550
|
-
reason: { type: 'string', description: 'Why the bypass is justified. Empty or whitespace-only is rejected — the recorded reason is the point.' },
|
|
551
|
-
operation: { type: 'string', description: 'Caller-declared intent label (e.g. "repair-malformed-record"). Recorded for analysis; unverifiable by construction.' },
|
|
552
|
-
},
|
|
553
|
-
},
|
|
554
|
-
},
|
|
555
530
|
{
|
|
556
531
|
name: 'get_changelog_entries',
|
|
557
532
|
effect: 'read',
|
|
@@ -26,18 +26,12 @@ export const SETUP_TOOLS = new Set([
|
|
|
26
26
|
/**
|
|
27
27
|
* Management/approval/completion tools an implementer context must not wield.
|
|
28
28
|
*
|
|
29
|
-
* COMP-COVERAGE-GATE (2026-08-24) added
|
|
29
|
+
* COMP-COVERAGE-GATE (2026-08-24) added `roadmap_xref_push`, found by
|
|
30
30
|
* `checkAuthorizationCoverage`'s C4 check — mutating tools this list had never
|
|
31
|
-
* been asked about.
|
|
32
|
-
* self-approve, self-complete, or mutate roadmap status") and no design ever
|
|
33
|
-
* ruled that an implementer may call
|
|
31
|
+
* been asked about. It postdates COMP-MCP-ENFORCE-1's charter ("cannot
|
|
32
|
+
* self-approve, self-complete, or mutate roadmap status"), and no design ever
|
|
33
|
+
* ruled that an implementer may call it:
|
|
34
34
|
*
|
|
35
|
-
* - `canon_override_grant` — the escape hatch FROM canon enforcement was
|
|
36
|
-
* callable by the profile SUBJECT to it. COMP-CANON-OVERRIDE already reasoned
|
|
37
|
-
* that the override must not be grantable for its own governance state
|
|
38
|
-
* (`overrideEligible: false`); this is the same argument one level up, at the
|
|
39
|
-
* caller instead of the target. An implementer that hits a canon block must
|
|
40
|
-
* escalate, not self-authorise.
|
|
41
35
|
* - `roadmap_xref_push` — writes EXTERNAL trackers (github issues, sibling
|
|
42
36
|
* repos). An implementer session should not be reaching outside the repo.
|
|
43
37
|
*
|
|
@@ -47,8 +41,8 @@ export const SETUP_TOOLS = new Set([
|
|
|
47
41
|
* recorded decision, not an omission. That ruling is now recorded machine-side
|
|
48
42
|
* in `C4_EXCEPTIONS` (lib/coverage-gate.js) so the gate stops re-raising it.
|
|
49
43
|
*
|
|
50
|
-
* Verified before adding
|
|
51
|
-
*
|
|
44
|
+
* Verified before adding it: no pipeline spec, prompt template or server flow
|
|
45
|
+
* invokes it from an implementer-profile session.
|
|
52
46
|
*/
|
|
53
47
|
const IMPLEMENTER_DENY = [
|
|
54
48
|
'approve_gate', 'complete_feature', 'kill_feature',
|
|
@@ -56,7 +50,6 @@ const IMPLEMENTER_DENY = [
|
|
|
56
50
|
// COMP-LIFECYCLE-BACKFILL: a completion is a management act, backfilled or not.
|
|
57
51
|
'backfill_completion',
|
|
58
52
|
// ── added by COMP-COVERAGE-GATE C4 ──
|
|
59
|
-
'canon_override_grant',
|
|
60
53
|
'roadmap_xref_push',
|
|
61
54
|
];
|
|
62
55
|
|
package/server/stratum-client.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Contract:
|
|
9
9
|
* - Query calls: 5s timeout, 1 retry on timeout, no retry on error
|
|
10
|
-
* - Mutation calls:
|
|
10
|
+
* - Mutation calls: 30s timeout, no retry (mutations are not idempotent to retry)
|
|
11
11
|
* - Exit 0 → parse stdout as JSON, return result
|
|
12
12
|
* - Exit 2 → conflict (idempotency), return { conflict: true, ... }
|
|
13
13
|
* - Non-zero → log stderr internally, return { error: { code, message, detail } }
|
|
@@ -22,7 +22,13 @@ import { resolveStratumBin, resolveStratumEngine as resolveEngine } from '../lib
|
|
|
22
22
|
let _execFile = _execFileDefault;
|
|
23
23
|
export function _testOnly_setExecFile(fn) { _execFile = fn; }
|
|
24
24
|
const QUERY_TIMEOUT_MS = 5_000;
|
|
25
|
-
|
|
25
|
+
// Measured 2026-09-07 on this seam: a `guard transition` subprocess costs
|
|
26
|
+
// 1.5-3.9 s idle (node startup + the stratum CLI module graph + guard store IO).
|
|
27
|
+
// A 10 s budget left under 3x headroom, and under full-suite load 6 of 200
|
|
28
|
+
// lifecycle-guard-e2e runs blew it; at 30 s the same probe under the same load
|
|
29
|
+
// was 0 of 200. A mutation timeout is a fail-closed refusal to the caller, so
|
|
30
|
+
// the budget must clear a loaded machine, not just an idle one.
|
|
31
|
+
const MUTATION_TIMEOUT_MS = 30_000;
|
|
26
32
|
|
|
27
33
|
// ---------------------------------------------------------------------------
|
|
28
34
|
// Engine selection (COMP-STRATUM-TS)
|
|
@@ -81,8 +87,28 @@ function _spawnResult(bin, err, out, err2) {
|
|
|
81
87
|
const stdout = out || '';
|
|
82
88
|
let stderr = err2 || '';
|
|
83
89
|
let code;
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// A timeout kill NEVER reaches the execFile CALLBACK as `code: 'ETIMEDOUT'` —
|
|
91
|
+
// node kills the child and reports `{ code: null, killed: true, signal:
|
|
92
|
+
// 'SIGTERM' }`. Matching only on ETIMEDOUT left every real timeout falling
|
|
93
|
+
// through to the generic `code = 1` branch below, so the TIMEOUT arms of
|
|
94
|
+
// runQuery/runMutation/runGuard (and runQuery's retry) were unreachable and a
|
|
95
|
+
// timed-out guard transition surfaced as `UNKNOWN` — rendered by the lifecycle
|
|
96
|
+
// routes as "transition refused by guard", i.e. an infrastructure timeout
|
|
97
|
+
// claiming the evidence was evaluated and rejected. Measured 2026-09-07:
|
|
98
|
+
// 6/200 lifecycle-guard-e2e runs under full-suite load, every one this shape.
|
|
99
|
+
// (`ETIMEDOUT` is still matched: execFileSync/spawnSync do set it, and
|
|
100
|
+
// probeStratumBin in lib/stratum-engine.js already checks all three.)
|
|
101
|
+
// `killed` is set only when NODE killed the child (timeout). A child that
|
|
102
|
+
// died to an outside signal reports `signal` with `killed: false`; that is
|
|
103
|
+
// not a timeout and is named as what it was rather than relabelled.
|
|
104
|
+
if (err?.code === 'ETIMEDOUT' || err?.killed === true) {
|
|
105
|
+
code = -1;
|
|
106
|
+
stderr = stderr || err.message || String(err);
|
|
107
|
+
} else if (typeof err?.code === 'number') code = err.code;
|
|
108
|
+
else if (err?.signal != null) {
|
|
109
|
+
code = 1;
|
|
110
|
+
stderr = stderr || `stratum child killed by ${err.signal}`;
|
|
111
|
+
}
|
|
86
112
|
else if (typeof err?.code === 'string' && _SPAWN_CODES.has(err.code)) {
|
|
87
113
|
code = -2;
|
|
88
114
|
stderr = _spawnRemedy(bin, err.code);
|
|
@@ -102,6 +128,18 @@ function _spawnRemedy(bin, code) {
|
|
|
102
128
|
return `${bin} (TS stratum engine) failed to spawn (${code}). Install @smartmemory/stratum or set COMPOSE_STRATUM_TS_CLI_BIN`;
|
|
103
129
|
}
|
|
104
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A bounded, diagnosable tail of what the subprocess actually produced. The
|
|
133
|
+
* TIMEOUT/PARSE_ERROR envelopes used to carry `detail: ''`, which made every
|
|
134
|
+
* such failure indistinguishable from every other one in a log. stderr is not
|
|
135
|
+
* forwarded to REST callers (see the module contract) — this detail is the
|
|
136
|
+
* process-level shape (exit code + a stdout excerpt), not the child's stderr.
|
|
137
|
+
*/
|
|
138
|
+
function _detail(result) {
|
|
139
|
+
const out = String(result?.stdout ?? '').trim();
|
|
140
|
+
return `exit=${result?.code}${out ? ` stdout=${JSON.stringify(out.slice(0, 400))}` : ' stdout=<empty>'}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
105
143
|
/**
|
|
106
144
|
* Run a query command (read-only). Retries once on timeout.
|
|
107
145
|
*
|
|
@@ -115,7 +153,7 @@ async function runQuery(args) {
|
|
|
115
153
|
// Retry once on timeout
|
|
116
154
|
result = await spawnStratum(args, QUERY_TIMEOUT_MS, bin);
|
|
117
155
|
if (result.code === -1) {
|
|
118
|
-
return { error: { code: 'TIMEOUT', message: 'Stratum query timed out', detail:
|
|
156
|
+
return { error: { code: 'TIMEOUT', message: 'Stratum query timed out', detail: _detail(result) } };
|
|
119
157
|
}
|
|
120
158
|
}
|
|
121
159
|
|
|
@@ -136,7 +174,7 @@ async function runQuery(args) {
|
|
|
136
174
|
try {
|
|
137
175
|
return JSON.parse(result.stdout);
|
|
138
176
|
} catch {
|
|
139
|
-
return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail:
|
|
177
|
+
return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: _detail(result) } };
|
|
140
178
|
}
|
|
141
179
|
}
|
|
142
180
|
|
|
@@ -149,7 +187,7 @@ async function runMutation(args) {
|
|
|
149
187
|
const result = await spawnStratum(args, MUTATION_TIMEOUT_MS, flowGateBin());
|
|
150
188
|
|
|
151
189
|
if (result.code === -1) {
|
|
152
|
-
return { error: { code: 'TIMEOUT', message: 'Stratum gate timed out', detail:
|
|
190
|
+
return { error: { code: 'TIMEOUT', message: 'Stratum gate timed out', detail: _detail(result) } };
|
|
153
191
|
}
|
|
154
192
|
|
|
155
193
|
if (result.code === -2) {
|
|
@@ -177,7 +215,7 @@ async function runMutation(args) {
|
|
|
177
215
|
try {
|
|
178
216
|
return JSON.parse(result.stdout);
|
|
179
217
|
} catch {
|
|
180
|
-
return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail:
|
|
218
|
+
return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: _detail(result) } };
|
|
181
219
|
}
|
|
182
220
|
}
|
|
183
221
|
|
|
@@ -226,7 +264,7 @@ async function runGuard(action, kwargs, timeoutMs = MUTATION_TIMEOUT_MS, extraEn
|
|
|
226
264
|
const result = await spawnStratumStdin(['guard', action], JSON.stringify(kwargs), timeoutMs, flowGateBin(), extraEnv);
|
|
227
265
|
|
|
228
266
|
if (result.code === -1) {
|
|
229
|
-
return { error: { code: 'TIMEOUT', message: 'Stratum guard timed out', detail:
|
|
267
|
+
return { error: { code: 'TIMEOUT', message: 'Stratum guard timed out', detail: _detail(result) } };
|
|
230
268
|
}
|
|
231
269
|
if (result.code === -2) {
|
|
232
270
|
console.error('[stratum-client] guard spawn failure:', result.stderr);
|
|
@@ -247,7 +285,7 @@ async function runGuard(action, kwargs, timeoutMs = MUTATION_TIMEOUT_MS, extraEn
|
|
|
247
285
|
try {
|
|
248
286
|
return JSON.parse(result.stdout);
|
|
249
287
|
} catch {
|
|
250
|
-
return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail:
|
|
288
|
+
return { error: { code: 'PARSE_ERROR', message: 'Stratum returned invalid JSON', detail: _detail(result) } };
|
|
251
289
|
}
|
|
252
290
|
}
|
|
253
291
|
|
|
@@ -367,17 +405,25 @@ export async function guardTransition({ resourceId, fromState, toState, artifact
|
|
|
367
405
|
}
|
|
368
406
|
|
|
369
407
|
/**
|
|
370
|
-
* The single sanctioned bypass of predicate verification. Requires
|
|
371
|
-
*
|
|
372
|
-
*
|
|
408
|
+
* The single sanctioned bypass of predicate verification. Requires a signed
|
|
409
|
+
* one-shot AUTHORIZATION, a human resolver, and a rationale. Records a
|
|
410
|
+
* 'deviation' ledger entry.
|
|
411
|
+
*
|
|
412
|
+
* Corrected 2026-09-07: this wrapper sent `override_token`, the shared secret
|
|
413
|
+
* stratum retired in STRAT-GUARD-AUTHZ @3647b4c. Stratum now reads
|
|
414
|
+
* `authorization` (`ts/src/mcp/server.ts:270`) — an sshsig over a payload it
|
|
415
|
+
* reconstructs, bound to the resource's ledger head — so every call this wrapper
|
|
416
|
+
* could have made was destined to fail on a missing authorization. It has no
|
|
417
|
+
* production caller; the field name is fixed so the first one does not inherit
|
|
418
|
+
* the break.
|
|
373
419
|
* @returns {Promise<{status:string,ledger_ref:string,current_state:string}|ErrorResult>}
|
|
374
420
|
*/
|
|
375
|
-
export async function guardOverride({ resourceId, fromState, toState,
|
|
421
|
+
export async function guardOverride({ resourceId, fromState, toState, authorization, rationale, resolvedBy = 'human' }) {
|
|
376
422
|
return runGuard('override', _compact({
|
|
377
423
|
resource_id: resourceId,
|
|
378
424
|
from_state: fromState,
|
|
379
425
|
to_state: toState,
|
|
380
|
-
|
|
426
|
+
authorization,
|
|
381
427
|
rationale,
|
|
382
428
|
resolved_by: resolvedBy,
|
|
383
429
|
}));
|
package/server/supervisor.js
CHANGED
|
@@ -22,6 +22,8 @@ import { COMPOSE_HOME, getTargetRoot, ensureDataDir } from './project-root.js';
|
|
|
22
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
console.log('[supervisor] Target project:', getTargetRoot());
|
|
24
24
|
const PID_FILE = path.join(COMPOSE_HOME, '.compose-supervisor.pid');
|
|
25
|
+
const VITE_BIN = path.join(COMPOSE_HOME, 'node_modules', '.bin', 'vite');
|
|
26
|
+
const IS_SOURCE_CHECKOUT = fs.existsSync(path.join(COMPOSE_HOME, '.git'));
|
|
25
27
|
|
|
26
28
|
const PROCESSES = [
|
|
27
29
|
{
|
|
@@ -36,13 +38,25 @@ const PROCESSES = [
|
|
|
36
38
|
port: process.env.AGENT_PORT || 4002,
|
|
37
39
|
type: 'fork',
|
|
38
40
|
},
|
|
39
|
-
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
if (IS_SOURCE_CHECKOUT) {
|
|
44
|
+
delete process.env.COMPOSE_PACKAGED_UI;
|
|
45
|
+
if (!fs.existsSync(VITE_BIN)) {
|
|
46
|
+
console.error(`[supervisor] Vite is required in a Compose source checkout but was not found at ${VITE_BIN}`);
|
|
47
|
+
console.error(`[supervisor] Run \`npm install\` in ${COMPOSE_HOME}, then retry \`compose start\`.`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
PROCESSES.push({
|
|
40
51
|
name: 'vite',
|
|
41
|
-
command:
|
|
52
|
+
command: VITE_BIN,
|
|
42
53
|
port: process.env.VITE_PORT || 5195,
|
|
43
54
|
type: 'spawn',
|
|
44
|
-
}
|
|
45
|
-
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
process.env.COMPOSE_PACKAGED_UI = '1';
|
|
58
|
+
console.log('[supervisor] Packaged install: serving cockpit from dist/ through the API server');
|
|
59
|
+
}
|
|
46
60
|
|
|
47
61
|
const MIN_BACKOFF = 500;
|
|
48
62
|
const MAX_BACKOFF = 10_000;
|
package/server/vision-routes.js
CHANGED
|
@@ -53,7 +53,7 @@ import { addOpenLoop, resolveOpenLoop, listOpenLoops } from './open-loops-store.
|
|
|
53
53
|
import {
|
|
54
54
|
TERMINAL,
|
|
55
55
|
guardedTransition, ensureGuard, projectFeatureStatus,
|
|
56
|
-
verifyCompletionEvidence, guardTestCommand,
|
|
56
|
+
verifyCompletionEvidence, guardTestCommand, isGuardInfraError,
|
|
57
57
|
} from './lifecycle-guard.js';
|
|
58
58
|
import {
|
|
59
59
|
transitionsOf, skippableOf, completablePhaseOf, getMode, resolveMode,
|
|
@@ -85,6 +85,12 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
85
85
|
// COMP-MCP-ENFORCE: when enabled, lifecycle transitions are verdict-gated by
|
|
86
86
|
// stratum's STRAT-GUARD (fail-closed). Default OFF — legacy behavior intact.
|
|
87
87
|
const guardEnabled = () => capabilities?.guard === true;
|
|
88
|
+
// A guard that never answered is 503 "guard unavailable"; a guard that
|
|
89
|
+
// answered no is 422 "transition refused by guard". Collapsing them told the
|
|
90
|
+
// user their evidence was rejected when the subprocess had merely timed out.
|
|
91
|
+
const guardFailure = (res, g, from, to) => isGuardInfraError(g)
|
|
92
|
+
? res.status(503).json({ error: 'guard unavailable', from, to, guardError: g.error })
|
|
93
|
+
: res.status(422).json({ error: 'transition refused by guard', from, to, verdict: g.verdict, guardError: g.error });
|
|
88
94
|
|
|
89
95
|
// COMP-MCP-ENFORCE Slice 4: opt-in loopback REST auth on vision MUTATION
|
|
90
96
|
// endpoints (lifecycle transitions, iterations, gate resolve, item CRUD,
|
|
@@ -404,7 +410,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
404
410
|
// COMP-MCP-ENFORCE: verdict-gate the transition (fail-closed) before mutating.
|
|
405
411
|
if (guardEnabled()) {
|
|
406
412
|
const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent', mode: modeOf(item) });
|
|
407
|
-
if (!g.applied) return res
|
|
413
|
+
if (!g.applied) return guardFailure(res, g, from, targetPhase);
|
|
408
414
|
}
|
|
409
415
|
|
|
410
416
|
item.lifecycle.currentPhase = targetPhase;
|
|
@@ -446,7 +452,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
|
|
|
446
452
|
// COMP-MCP-ENFORCE: verdict-gate the skip (fail-closed) before mutating.
|
|
447
453
|
if (guardEnabled()) {
|
|
448
454
|
const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent', mode: modeOf(item) });
|
|
449
|
-
if (!g.applied) return res
|
|
455
|
+
if (!g.applied) return guardFailure(res, g, from, targetPhase);
|
|
450
456
|
}
|
|
451
457
|
|
|
452
458
|
item.lifecycle.currentPhase = targetPhase;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{aq as o,ar as n}from"./App-Z4MU-H_F.js";const t=(r,a)=>o.lang.round(n.parse(r)[a]);export{t as c};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-JytR14a9.js";import{_ as i}from"./App-Z4MU-H_F.js";import"./chunk-FMBD7UC4-C9s7KR9m.js";import"./chunk-YZCP3GAM-iE_tzriw.js";import"./chunk-55IACEB6-B4Q97BCP.js";import"./chunk-EDXVE4YY-R_qarkSf.js";import"./mobile-B7m9EO9D.js";import"./index-BEfrNBp8.js";import"./graph-CI_1htl0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-JytR14a9.js";import{_ as i}from"./App-Z4MU-H_F.js";import"./chunk-FMBD7UC4-C9s7KR9m.js";import"./chunk-YZCP3GAM-iE_tzriw.js";import"./chunk-55IACEB6-B4Q97BCP.js";import"./chunk-EDXVE4YY-R_qarkSf.js";import"./mobile-B7m9EO9D.js";import"./index-BEfrNBp8.js";import"./graph-CI_1htl0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{b as r}from"./graph-BNzKGvoy.js";var e=4;function a(o){return r(o,e)}export{a as c};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as r,b as e,a,S as s}from"./chunk-OYMX7WX6-BySQzVxc.js";import{_ as i}from"./App-Z4MU-H_F.js";import"./chunk-55IACEB6-B4Q97BCP.js";import"./chunk-EDXVE4YY-R_qarkSf.js";import"./mobile-B7m9EO9D.js";import"./index-BEfrNBp8.js";import"./graph-CI_1htl0.js";var n={parser:a,get db(){return new s(2)},renderer:e,styles:r,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{n as diagram};
|
package/lib/append-integrity.js
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* append-integrity.js — integrity for append-only canon files.
|
|
3
|
-
*
|
|
4
|
-
* COMP-CANON-OVERRIDE S1. Shared with COMP-CANON-ATTEST, which needs the same
|
|
5
|
-
* primitive for `docs/judgment/records/ledger.jsonl`.
|
|
6
|
-
*
|
|
7
|
-
* A whole-file hash over an append-only file churns on every append and so
|
|
8
|
-
* carries no signal about history. The useful question is narrower: **is the
|
|
9
|
-
* prefix we previously attested still byte-identical?** That is what
|
|
10
|
-
* `{length, prefix_hash}` answers — `prefix_hash` is sha256 over
|
|
11
|
-
* `bytes[0, length)`, so a legitimate append (which only extends) verifies
|
|
12
|
-
* clean while any rewrite of earlier content does not.
|
|
13
|
-
*
|
|
14
|
-
* A `{length, tail_hash}` variant was tried first and rejected: hashing the
|
|
15
|
-
* trailing bytes says nothing about the prefix, so an in-place edit of an
|
|
16
|
-
* early row that preserves total byte length — the shape a careless
|
|
17
|
-
* `sed -i 's/park/ship/'` takes — produced a byte-identical attestation.
|
|
18
|
-
*
|
|
19
|
-
* SCOPE: this is drift detection, not enforcement. It catches careless and
|
|
20
|
-
* accidental corruption. A deliberate actor who rewrites the file AND
|
|
21
|
-
* recomputes the baseline passes, because both live in the workspace the
|
|
22
|
-
* actor can write. See design.md, "The in-workspace ceiling".
|
|
23
|
-
*
|
|
24
|
-
* Pure — no I/O. Callers own reading the file and storing the baseline.
|
|
25
|
-
*/
|
|
26
|
-
import { createHash } from 'node:crypto';
|
|
27
|
-
|
|
28
|
-
/** @typedef {{ length: number, prefix_hash: string }} AppendBaseline */
|
|
29
|
-
/** @typedef {{ ok: boolean, kind: 'clean'|'shrunk'|'prefix_changed'|'malformed_baseline' }} AppendVerdict */
|
|
30
|
-
|
|
31
|
-
const HEX64 = /^[0-9a-f]{64}$/;
|
|
32
|
-
|
|
33
|
-
function toBuffer(bytes) {
|
|
34
|
-
return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? '');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sha256(buf) {
|
|
38
|
-
return createHash('sha256').update(buf).digest('hex');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Capture the attestable state of an append-only file.
|
|
43
|
-
*
|
|
44
|
-
* @param {Buffer|string} bytes current file contents
|
|
45
|
-
* @returns {AppendBaseline}
|
|
46
|
-
*/
|
|
47
|
-
export function baselineFor(bytes) {
|
|
48
|
-
const buf = toBuffer(bytes);
|
|
49
|
-
return { length: buf.length, prefix_hash: sha256(buf) };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Verify that `bytes` is `baseline` plus zero or more appended bytes.
|
|
54
|
-
*
|
|
55
|
-
* Fails closed on a malformed baseline: a missing or nonsensical baseline is
|
|
56
|
-
* never treated as "nothing to check". That mirrors the malformed-record rule
|
|
57
|
-
* the judgment attestation already follows (S5 R4).
|
|
58
|
-
*
|
|
59
|
-
* @param {Buffer|string} bytes current file contents
|
|
60
|
-
* @param {AppendBaseline} baseline previously attested state
|
|
61
|
-
* @returns {AppendVerdict}
|
|
62
|
-
*/
|
|
63
|
-
export function verifyAppend(bytes, baseline) {
|
|
64
|
-
if (
|
|
65
|
-
!baseline
|
|
66
|
-
|| typeof baseline !== 'object'
|
|
67
|
-
|| !Number.isInteger(baseline.length)
|
|
68
|
-
|| baseline.length < 0
|
|
69
|
-
|| typeof baseline.prefix_hash !== 'string'
|
|
70
|
-
|| !HEX64.test(baseline.prefix_hash)
|
|
71
|
-
) {
|
|
72
|
-
return { ok: false, kind: 'malformed_baseline' };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const buf = toBuffer(bytes);
|
|
76
|
-
if (buf.length < baseline.length) return { ok: false, kind: 'shrunk' };
|
|
77
|
-
if (sha256(buf.subarray(0, baseline.length)) !== baseline.prefix_hash) {
|
|
78
|
-
return { ok: false, kind: 'prefix_changed' };
|
|
79
|
-
}
|
|
80
|
-
return { ok: true, kind: 'clean' };
|
|
81
|
-
}
|