sentinelayer-cli 0.46.4 → 0.48.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/README.md +63 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +46 -8
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +105 -47
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/auth.js +24 -4
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +607 -57
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +28 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +322 -71
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §EW02 — Graph contract (R02): assertions, entities, links, and
|
|
3
|
+
* provenance classification.
|
|
4
|
+
*
|
|
5
|
+
* Every assertion in the ENGRAM graph carries an explicit provenance class:
|
|
6
|
+
* observed — direct measurement or event capture (e.g. session event)
|
|
7
|
+
* authoritative— signed/verified by a trust anchor (e.g. AIdenID receipt)
|
|
8
|
+
* derived — computed from other assertions (e.g. recall ranking, summary)
|
|
9
|
+
* unknown — provenance cannot be determined; treated as non-authoritative
|
|
10
|
+
*
|
|
11
|
+
* graphSchema checks serializable shape and deterministic graph consistency.
|
|
12
|
+
* validateGraphIntegrity additionally requires an out-of-band verifier before
|
|
13
|
+
* accepting any authoritative provenance. Neither check grants permission to
|
|
14
|
+
* execute an action (ticket 73ead329).
|
|
15
|
+
*
|
|
16
|
+
* All schemas are strict: unknown/unsupported fields produce validation errors
|
|
17
|
+
* rather than being silently dropped (luna-judge finding F3).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { moneySchema } from "./money.js";
|
|
22
|
+
import { GRAPH_SCHEMA_VERSION } from "./versions.js";
|
|
23
|
+
import { nonblankIdentitySchema } from "./identity.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Provenance classification (R09 truth). Must be one of exactly four values.
|
|
27
|
+
*/
|
|
28
|
+
export const PROVENANCE_CLASSES = Object.freeze([
|
|
29
|
+
"observed",
|
|
30
|
+
"authoritative",
|
|
31
|
+
"derived",
|
|
32
|
+
"unknown",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** Schema validity level for an assertion. */
|
|
36
|
+
export const SCHEMA_VALIDITY = Object.freeze([
|
|
37
|
+
"valid",
|
|
38
|
+
"invalid",
|
|
39
|
+
"unvalidated",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Model confidence: probability-like range with explicit "unavailable" sentinel. */
|
|
43
|
+
export const MODEL_CONFIDENCE = Object.freeze([
|
|
44
|
+
"high",
|
|
45
|
+
"medium",
|
|
46
|
+
"low",
|
|
47
|
+
"unavailable",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export const provenanceClassSchema = z.enum(["observed", "authoritative", "derived", "unknown", "unresolved"]);
|
|
51
|
+
export const schemaValiditySchema = z.enum(["valid", "invalid", "unvalidated"]);
|
|
52
|
+
export const modelConfidenceSchema = z.enum(["high", "medium", "low", "unavailable"]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An evidence reference: ties an assertion to a verifiable evidence record.
|
|
56
|
+
* Required for authoritative classification (luna-judge finding F2).
|
|
57
|
+
*/
|
|
58
|
+
export const evidenceRefSchema = z.object({
|
|
59
|
+
address: nonblankIdentitySchema,
|
|
60
|
+
verifiedAt: z.string().datetime(),
|
|
61
|
+
verifier: nonblankIdentitySchema,
|
|
62
|
+
}).strict();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An entity node in the ENGRAM graph.
|
|
66
|
+
*
|
|
67
|
+
* Entities carrying factual content (label, attributes) declare provenance,
|
|
68
|
+
* source version and capture time. Authority requires trusted evidence.
|
|
69
|
+
*/
|
|
70
|
+
export const entitySchema = z.object({
|
|
71
|
+
id: nonblankIdentitySchema,
|
|
72
|
+
type: nonblankIdentitySchema,
|
|
73
|
+
label: z.string().optional(),
|
|
74
|
+
attributes: z.record(z.string(), z.json()).optional(),
|
|
75
|
+
provenance: provenanceClassSchema.optional(),
|
|
76
|
+
evidenceRef: evidenceRefSchema.optional(),
|
|
77
|
+
tenantScope: nonblankIdentitySchema,
|
|
78
|
+
sourceId: nonblankIdentitySchema,
|
|
79
|
+
sourceVersion: nonblankIdentitySchema,
|
|
80
|
+
capturedAt: z.string().datetime().optional(),
|
|
81
|
+
}).strict().superRefine((data, ctx) => {
|
|
82
|
+
const hasFacts = data.label !== undefined ||
|
|
83
|
+
(data.attributes && Object.keys(data.attributes).length > 0);
|
|
84
|
+
if (hasFacts && !data.capturedAt) {
|
|
85
|
+
ctx.addIssue({ code: "custom", path: ["capturedAt"],
|
|
86
|
+
message: "entity with factual content (label or attributes) requires capturedAt" });
|
|
87
|
+
}
|
|
88
|
+
if (hasFacts && !data.provenance) {
|
|
89
|
+
ctx.addIssue({ code: "custom", path: ["provenance"],
|
|
90
|
+
message: "entity with factual content requires an explicit provenance class" });
|
|
91
|
+
}
|
|
92
|
+
if (data.provenance === "authoritative" && !data.evidenceRef) {
|
|
93
|
+
ctx.addIssue({ code: "custom", path: ["evidenceRef"],
|
|
94
|
+
message: "authoritative entity requires an evidenceRef" });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A link between two graph entities.
|
|
100
|
+
*
|
|
101
|
+
* Every link is a served graph fact and needs a versioned source and capture
|
|
102
|
+
* time, even when its provenance remains unknown. Authoritative links also
|
|
103
|
+
* require an evidenceAddress.
|
|
104
|
+
*/
|
|
105
|
+
export const linkSchema = z.object({
|
|
106
|
+
source: nonblankIdentitySchema,
|
|
107
|
+
target: nonblankIdentitySchema,
|
|
108
|
+
predicate: nonblankIdentitySchema,
|
|
109
|
+
provenance: provenanceClassSchema,
|
|
110
|
+
confidence: modelConfidenceSchema.optional(),
|
|
111
|
+
evidenceAddress: nonblankIdentitySchema.optional(),
|
|
112
|
+
tenantScope: nonblankIdentitySchema,
|
|
113
|
+
sourceId: nonblankIdentitySchema,
|
|
114
|
+
sourceVersion: nonblankIdentitySchema,
|
|
115
|
+
capturedAt: z.string().datetime(),
|
|
116
|
+
}).strict().superRefine((link, ctx) => {
|
|
117
|
+
if (link.provenance === "authoritative" && !link.evidenceAddress) {
|
|
118
|
+
ctx.addIssue({ code: "custom", path: ["evidenceAddress"], message: "authoritative link requires evidenceAddress" });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Freshness marker (R09 truth). Every assertion that can go stale must carry
|
|
124
|
+
* an asOf watermark. Absence means "unknown freshness" — disclosed, never implied.
|
|
125
|
+
*/
|
|
126
|
+
export const freshnessSchema = z.object({
|
|
127
|
+
asOf: z.string().datetime().optional(),
|
|
128
|
+
staleAfterMs: z.number().int().positive().optional(),
|
|
129
|
+
isStale: z.boolean().optional(),
|
|
130
|
+
coverageRatio: z.number().min(0).max(1).optional(),
|
|
131
|
+
}).strict().superRefine((freshness, ctx) => {
|
|
132
|
+
if (freshness.isStale === undefined) return;
|
|
133
|
+
for (const field of ["asOf", "staleAfterMs"]) {
|
|
134
|
+
if (freshness[field] === undefined) {
|
|
135
|
+
ctx.addIssue({ code: "custom", path: [field],
|
|
136
|
+
message: `isStale requires ${field} to establish a freshness basis` });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A versioned source descriptor. Every assertion must carry a source version
|
|
143
|
+
* and tenant scope (luna-judge finding F2).
|
|
144
|
+
*/
|
|
145
|
+
export const sourceDescriptorSchema = z.object({
|
|
146
|
+
sourceId: nonblankIdentitySchema,
|
|
147
|
+
version: nonblankIdentitySchema,
|
|
148
|
+
capturedAt: z.string().datetime(),
|
|
149
|
+
tenantScope: nonblankIdentitySchema,
|
|
150
|
+
}).strict();
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* An assertion in the ENGRAM graph.
|
|
154
|
+
*
|
|
155
|
+
* Every assertion must be classifiable as observed/authoritative/derived/unknown,
|
|
156
|
+
* must declare its schema validity, and must carry freshness markers when
|
|
157
|
+
* applicable. Tenant scope and source version are required (F2).
|
|
158
|
+
* Authoritative assertions MUST carry an evidenceRef (luna-judge P1 finding).
|
|
159
|
+
*/
|
|
160
|
+
export const assertionSchema = z.object({
|
|
161
|
+
id: nonblankIdentitySchema,
|
|
162
|
+
subject: nonblankIdentitySchema,
|
|
163
|
+
predicate: nonblankIdentitySchema,
|
|
164
|
+
object: z.json(),
|
|
165
|
+
provenance: provenanceClassSchema,
|
|
166
|
+
schemaValidity: schemaValiditySchema,
|
|
167
|
+
modelConfidence: modelConfidenceSchema.optional(),
|
|
168
|
+
freshness: freshnessSchema.optional(),
|
|
169
|
+
evidenceRef: evidenceRefSchema.optional(),
|
|
170
|
+
source: sourceDescriptorSchema,
|
|
171
|
+
}).strict().refine(
|
|
172
|
+
(data) => {
|
|
173
|
+
if (data.provenance === "authoritative" && !data.evidenceRef) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
},
|
|
178
|
+
{ message: "authoritative provenance requires an evidenceRef with a verified address", path: ["evidenceRef"] },
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A versioned ENGRAM graph.
|
|
183
|
+
*
|
|
184
|
+
* Every graph version carries its own schema version, a set of entities,
|
|
185
|
+
* links, and assertions, plus coverage and freshness metadata.
|
|
186
|
+
*/
|
|
187
|
+
export const graphSchema = z.object({
|
|
188
|
+
unresolvedFacts: z.array(z.object({
|
|
189
|
+
field: z.string().optional(),
|
|
190
|
+
path: z.string().optional(),
|
|
191
|
+
rawValue: z.json().optional(),
|
|
192
|
+
reason: z.string(),
|
|
193
|
+
entityLabel: z.string().optional(),
|
|
194
|
+
entityKind: z.string().optional(),
|
|
195
|
+
sourceId: z.string().optional(),
|
|
196
|
+
tenantScope: z.string().optional(),
|
|
197
|
+
})).optional(),
|
|
198
|
+
version: z.literal(GRAPH_SCHEMA_VERSION),
|
|
199
|
+
entities: z.array(entitySchema).optional(),
|
|
200
|
+
links: z.array(linkSchema).optional(),
|
|
201
|
+
assertions: z.array(assertionSchema),
|
|
202
|
+
coverage: z.object({
|
|
203
|
+
totalAssertions: z.number().int().nonnegative(),
|
|
204
|
+
byProvenance: z.partialRecord(provenanceClassSchema, z.number().int().nonnegative()).optional(),
|
|
205
|
+
bySchemaValidity: z.partialRecord(schemaValiditySchema, z.number().int().nonnegative()).optional(),
|
|
206
|
+
}).strict().optional(),
|
|
207
|
+
freshness: freshnessSchema.optional(),
|
|
208
|
+
generatedAt: z.string().datetime(),
|
|
209
|
+
}).strict().superRefine((data, ctx) => {
|
|
210
|
+
const generatedAtMs = Date.parse(data.generatedAt);
|
|
211
|
+
const capturedByGeneration = (timestamp, path) => {
|
|
212
|
+
if (timestamp && Date.parse(timestamp) > generatedAtMs) {
|
|
213
|
+
ctx.addIssue({ code: "custom", path, message: "fact time cannot be after graph generatedAt" });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const checkFreshness = (freshness, path) => {
|
|
217
|
+
capturedByGeneration(freshness?.asOf, [...path, "asOf"]);
|
|
218
|
+
if (freshness?.asOf && freshness.staleAfterMs != null && freshness.isStale != null) {
|
|
219
|
+
const ageMs = generatedAtMs - Date.parse(freshness.asOf);
|
|
220
|
+
if (freshness.isStale !== (ageMs >= freshness.staleAfterMs)) {
|
|
221
|
+
ctx.addIssue({ code: "custom", path: [...path, "isStale"], message: "isStale must match the age at graph generation" });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
checkFreshness(data.freshness, ["freshness"]);
|
|
226
|
+
(data.entities ?? []).forEach((entity, i) => {
|
|
227
|
+
capturedByGeneration(entity.capturedAt, ["entities", i, "capturedAt"]);
|
|
228
|
+
capturedByGeneration(entity.evidenceRef?.verifiedAt, ["entities", i, "evidenceRef", "verifiedAt"]);
|
|
229
|
+
});
|
|
230
|
+
data.assertions.forEach((assertion, i) => {
|
|
231
|
+
capturedByGeneration(assertion.source.capturedAt, ["assertions", i, "source", "capturedAt"]);
|
|
232
|
+
capturedByGeneration(assertion.evidenceRef?.verifiedAt, ["assertions", i, "evidenceRef", "verifiedAt"]);
|
|
233
|
+
checkFreshness(assertion.freshness, ["assertions", i, "freshness"]);
|
|
234
|
+
});
|
|
235
|
+
(data.links ?? []).forEach((link, i) => capturedByGeneration(link.capturedAt, ["links", i, "capturedAt"]));
|
|
236
|
+
|
|
237
|
+
const nodeScopes = new Map();
|
|
238
|
+
for (const [kind, nodes] of [["entities", data.entities ?? []], ["assertions", data.assertions]]) {
|
|
239
|
+
nodes.forEach((node, i) => {
|
|
240
|
+
if (nodeScopes.has(node.id)) {
|
|
241
|
+
ctx.addIssue({ code: "custom", path: [kind, i, "id"], message: `duplicate graph id ${node.id}` });
|
|
242
|
+
} else {
|
|
243
|
+
nodeScopes.set(node.id, kind === "entities" ? node.tenantScope : node.source.tenantScope);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (new Set(nodeScopes.values()).size > 1) {
|
|
248
|
+
ctx.addIssue({ code: "custom", path: ["assertions"], message: "graph facts must share one tenant scope" });
|
|
249
|
+
}
|
|
250
|
+
(data.links ?? []).forEach((link, i) => {
|
|
251
|
+
for (const endpoint of ["source", "target"]) {
|
|
252
|
+
if (!nodeScopes.has(link[endpoint])) {
|
|
253
|
+
ctx.addIssue({ code: "custom", path: ["links", i, endpoint], message: `dangling link ${endpoint}` });
|
|
254
|
+
} else if (nodeScopes.get(link[endpoint]) !== link.tenantScope) {
|
|
255
|
+
ctx.addIssue({ code: "custom", path: ["links", i, "tenantScope"], message: "link tenant scope must match both endpoints" });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
if (!data.coverage) return;
|
|
260
|
+
if (data.coverage.totalAssertions !== data.assertions.length) {
|
|
261
|
+
ctx.addIssue({ code: "custom", path: ["coverage", "totalAssertions"], message: "coverage totalAssertions must match assertions" });
|
|
262
|
+
}
|
|
263
|
+
for (const [field, key] of [["byProvenance", "provenance"], ["bySchemaValidity", "schemaValidity"]]) {
|
|
264
|
+
const claimed = data.coverage[field];
|
|
265
|
+
if (!claimed) continue;
|
|
266
|
+
for (const className of field === "byProvenance" ? PROVENANCE_CLASSES : SCHEMA_VALIDITY) {
|
|
267
|
+
const actual = data.assertions.filter((item) => item[key] === className).length;
|
|
268
|
+
if ((claimed[className] ?? 0) !== actual) {
|
|
269
|
+
ctx.addIssue({ code: "custom", path: ["coverage", field, className], message: `${className} count must match assertions` });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Classify an assertion's provenance from its context.
|
|
277
|
+
*
|
|
278
|
+
* Only returns "authoritative" when a caller-supplied trusted verifier checks
|
|
279
|
+
* the fact and reference. Serializable isVerified/verifiedBy fields are claims.
|
|
280
|
+
*
|
|
281
|
+
* @param {object} assertion
|
|
282
|
+
* @param {object} [ctx] — { evidenceRef, derivedFrom, observedAt }
|
|
283
|
+
* @param {{verifyEvidence?: (fact: object, reference: object) => boolean}} options
|
|
284
|
+
* @returns {string} one of PROVENANCE_CLASSES
|
|
285
|
+
*/
|
|
286
|
+
export function classifyProvenance(assertion, ctx = {}, { verifyEvidence } = {}) {
|
|
287
|
+
if (!assertion || !assertion.id) return "unknown";
|
|
288
|
+
if (ctx.derivedFrom && Array.isArray(ctx.derivedFrom) && ctx.derivedFrom.length > 0) return "derived";
|
|
289
|
+
// Serializable flags and verifier names are claims. Only a trusted caller's
|
|
290
|
+
// out-of-band verifier can bind this fact to an independently checked record.
|
|
291
|
+
if (typeof verifyEvidence === "function" && evidenceRefSchema.safeParse(ctx.evidenceRef).success) {
|
|
292
|
+
try {
|
|
293
|
+
if (verifyEvidence(assertion, ctx.evidenceRef) === true) return "authoritative";
|
|
294
|
+
} catch { /* unverifiable is not authoritative */ }
|
|
295
|
+
}
|
|
296
|
+
if (z.string().datetime({ offset: true }).safeParse(ctx.observedAt).success && Number.isFinite(Date.parse(ctx.observedAt))) return "observed";
|
|
297
|
+
return "unknown";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Money field names that must be validated with moneySchema.
|
|
302
|
+
* Covers common monetary field naming patterns.
|
|
303
|
+
*/
|
|
304
|
+
// Money field name set: covers common monetary field naming patterns.
|
|
305
|
+
// Fields whose name exactly matches one of these are expected to carry
|
|
306
|
+
// a canonical money object { amount, currency, exponent }, never a bare float.
|
|
307
|
+
// Non-monetary numeric fields like ratio, temperature, count are not in this set.
|
|
308
|
+
export const MONEY_FIELD_NAMES = new Set([
|
|
309
|
+
"amount", "Amount",
|
|
310
|
+
"price", "Price",
|
|
311
|
+
"cost", "Cost",
|
|
312
|
+
"fee", "Fee",
|
|
313
|
+
"total", "Total",
|
|
314
|
+
"balance", "Balance",
|
|
315
|
+
"payment", "Payment",
|
|
316
|
+
"subtotal", "Subtotal",
|
|
317
|
+
"total_amount", "totalAmount", "TotalAmount",
|
|
318
|
+
"unit_price", "unitPrice", "UnitPrice",
|
|
319
|
+
"tax_amount", "taxAmount", "TaxAmount",
|
|
320
|
+
"discount_amount", "discountAmount", "DiscountAmount",
|
|
321
|
+
"shipping_cost", "shippingCost", "ShippingCost",
|
|
322
|
+
"lowPrice", "LowPrice",
|
|
323
|
+
"highPrice", "HighPrice",
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
// Other predicates may name the same monetary fact in camel/snake case.
|
|
327
|
+
// Match the monetary noun at the end so totalCount and totalWeight remain
|
|
328
|
+
// ordinary quantities rather than being misclassified as prices.
|
|
329
|
+
const MONEY_FIELD_SUFFIXES = new Set([
|
|
330
|
+
"amount", "price", "cost", "fee", "total", "subtotal", "balance",
|
|
331
|
+
"payment", "revenue", "expense", "charge", "refund", "budget", "profit",
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
// Price/cost nouns remain monetary with a qualifier (basePriceRate); total
|
|
335
|
+
// and tax need a non-money measure exception (totalCount, taxRate). A bare
|
|
336
|
+
// transaction noun such as "order" or "invoice" is an entity, not an amount.
|
|
337
|
+
const STRONG_MONEY_FIELD_TOKENS = new Set([
|
|
338
|
+
"amount", "price", "cost", "fee", "subtotal", "balance", "payment",
|
|
339
|
+
"revenue", "expense", "charge", "refund", "budget", "profit", "vat", "msrp",
|
|
340
|
+
]);
|
|
341
|
+
const TRANSACTION_VALUE_TOKENS = new Set(["sale", "purchase", "invoice", "order"]);
|
|
342
|
+
const NON_MONETARY_MEASURE_TERMINALS = new Set([
|
|
343
|
+
"count", "weight", "length", "duration", "item", "items", "unit", "units", "ratio", "rate",
|
|
344
|
+
"percent", "percentage", "temperature", "volume", "area", "distance",
|
|
345
|
+
"height", "width", "score", "index",
|
|
346
|
+
]);
|
|
347
|
+
const NON_MONETARY_METADATA_TERMINALS = new Set([
|
|
348
|
+
"id", "label", "name", "status", "type", "currency", "code",
|
|
349
|
+
"reference", "number", "timestamp", "method", "description", "source", "version",
|
|
350
|
+
"center", "tier", "schedule", "policy", "owner", "term", "url", "exempt", "window",
|
|
351
|
+
"specification",
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
// A quantity is explicitly non-monetary. Currency codes and unambiguous
|
|
355
|
+
// currency words cannot turn a binary float into a monetary total.
|
|
356
|
+
const MONEY_UNIT_WORDS = new Set([
|
|
357
|
+
"dollar", "dollars", "cent", "cents", "euro",
|
|
358
|
+
"euros", "yen", "yuan", "won", "rupee", "rupees", "peso", "pesos",
|
|
359
|
+
"franc", "francs", "dinar", "dinars", "dirham", "dirhams",
|
|
360
|
+
"buck", "bucks",
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
// Compact names lack word boundaries. Recognize only deliberate modifier +
|
|
364
|
+
// monetary-terminal pairs; arbitrary suffix matching mistakes percent for
|
|
365
|
+
// cents, coffee for a fee, and nonprofit for profit.
|
|
366
|
+
const COMPACT_MONEY_MODIFIERS = Object.freeze([
|
|
367
|
+
"grand", "gross", "net", "order", "purchase", "sale", "invoice",
|
|
368
|
+
"unit", "base", "list", "retail", "shipping", "item", "lineitem",
|
|
369
|
+
"total", "tax", "discount", "refund", "payment",
|
|
370
|
+
]);
|
|
371
|
+
const COMPACT_MONEY_TERMINALS = new Set([
|
|
372
|
+
...MONEY_FIELD_SUFFIXES, "tax", ...MONEY_UNIT_WORDS,
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
// Quantity units are intentionally closed in this protocol version. An open
|
|
376
|
+
// string lets new currency names and slang bypass the canonical money type.
|
|
377
|
+
// A caller can still mislabel a price as a count under an unrelated field
|
|
378
|
+
// name; only source-bound evidence, not a field-name guess, resolves truth.
|
|
379
|
+
const NON_MONETARY_UNITS = new Set([
|
|
380
|
+
"count", "ratio", "percent", "percentage", "celsius", "fahrenheit", "kelvin",
|
|
381
|
+
"ms", "millisecond", "milliseconds", "s", "second", "seconds", "min", "minute", "minutes",
|
|
382
|
+
"hour", "hours", "day", "days", "byte", "bytes", "request", "requests",
|
|
383
|
+
"g", "gram", "grams", "kg", "kilogram", "kilograms", "mm", "cm", "m", "km",
|
|
384
|
+
"meter", "meters", "meters_per_second", "liter", "liters", "pixel", "pixels",
|
|
385
|
+
]);
|
|
386
|
+
|
|
387
|
+
/** Explicit non-money numeric fact. Bare JSON numbers are ambiguous. */
|
|
388
|
+
export const quantitySchema = z.object({
|
|
389
|
+
kind: z.literal("quantity"),
|
|
390
|
+
value: z.number().finite(),
|
|
391
|
+
unit: z.string().regex(/^[a-z][a-z0-9/_-]*$/),
|
|
392
|
+
}).strict().refine(({ unit }) => NON_MONETARY_UNITS.has(unit), {
|
|
393
|
+
message: "quantity unit must be a supported non-monetary unit; currency requires canonical money",
|
|
394
|
+
path: ["unit"],
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
function singularWord(word) {
|
|
398
|
+
if (word === "taxes") return "tax";
|
|
399
|
+
if (word.endsWith("ies") && word.length > 3) return `${word.slice(0, -3)}y`;
|
|
400
|
+
if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
|
|
401
|
+
return word;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function isPluralMoneyFieldName(key) {
|
|
405
|
+
const terminal = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase()
|
|
406
|
+
.split(/[^a-z0-9]+/).at(-1);
|
|
407
|
+
return terminal.endsWith("s") && terminal !== singularWord(terminal);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// A price specification holds prices: an object, or a list of objects (or
|
|
411
|
+
// nulls). A bare value, or a list holding one, is a price itself.
|
|
412
|
+
function holdsPriceSpecifications(value) {
|
|
413
|
+
// An explicit quantity is not a container: a price cannot hide in it as a count.
|
|
414
|
+
const isObject = (item) => item !== null && typeof item === "object" && !Array.isArray(item) && item.kind !== "quantity";
|
|
415
|
+
return value == null || isObject(value) ||
|
|
416
|
+
(Array.isArray(value) && value.every((item) => item === null || isObject(item)));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function isMoneyFieldName(key, value) {
|
|
420
|
+
if (MONEY_FIELD_NAMES.has(key)) return true;
|
|
421
|
+
const words = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase()
|
|
422
|
+
.split(/[^a-z0-9]+/).filter(Boolean).map(singularWord);
|
|
423
|
+
const suffix = words.at(-1);
|
|
424
|
+
// These nouns describe merchant metadata, including structured metadata.
|
|
425
|
+
// `priceNumber` still denotes money, and a numeric `priceTier` cannot
|
|
426
|
+
// disguise a price as a count. A typed refundWindow, paymentTerms or
|
|
427
|
+
// costCenter instead describes a duration or count, not an amount.
|
|
428
|
+
// Nested facts still pass through validateFactNumbers individually.
|
|
429
|
+
// A priceSpecification holds prices; a bare value under that name is one.
|
|
430
|
+
if (NON_MONETARY_METADATA_TERMINALS.has(suffix)) {
|
|
431
|
+
const moneyModifier = words.slice(0, -1).some((word) =>
|
|
432
|
+
STRONG_MONEY_FIELD_TOKENS.has(word) || word === "total" || word === "tax");
|
|
433
|
+
return moneyModifier && (suffix === "number" ||
|
|
434
|
+
(suffix === "tier" && value?.kind === "quantity") ||
|
|
435
|
+
(suffix === "specification" && !holdsPriceSpecifications(value)));
|
|
436
|
+
}
|
|
437
|
+
if (words.length === 1 && suffix.endsWith("number") &&
|
|
438
|
+
[...STRONG_MONEY_FIELD_TOKENS, "total", "tax"].some((word) => suffix.startsWith(word))) {
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
// Compact aliases need a recognized modifier and a whole terminal, since
|
|
442
|
+
// suffixes inside ordinary words are not semantic money boundaries.
|
|
443
|
+
if (words.length === 1 && COMPACT_MONEY_MODIFIERS.some((modifier) =>
|
|
444
|
+
suffix.startsWith(modifier) && COMPACT_MONEY_TERMINALS.has(suffix.slice(modifier.length)))) return true;
|
|
445
|
+
if (words.length === 1 && (STRONG_MONEY_FIELD_TOKENS.has(words[0]) ||
|
|
446
|
+
MONEY_UNIT_WORDS.has(words[0]) || words[0] === "total" || words[0] === "tax")) return true;
|
|
447
|
+
// Outside named metadata, a monetary field needs canonical money whatever
|
|
448
|
+
// representation the sender chose (including "TBD" or "19.99").
|
|
449
|
+
if (words.some((word) => STRONG_MONEY_FIELD_TOKENS.has(word) || MONEY_UNIT_WORDS.has(word))) return true;
|
|
450
|
+
if (suffix === "value" && words.slice(0, -1).some((word) => TRANSACTION_VALUE_TOKENS.has(word))) return true;
|
|
451
|
+
return words.some((word) => word === "total" || word === "tax") &&
|
|
452
|
+
!NON_MONETARY_MEASURE_TERMINALS.has(suffix);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Validate that all money fields across the graph use the money contract.
|
|
457
|
+
* Walks open-ended fact payloads, rejecting ambiguous bare numbers and
|
|
458
|
+
* accepting canonical money or explicitly typed non-money quantities.
|
|
459
|
+
* Familiar monetary field names are also checked directly.
|
|
460
|
+
*
|
|
461
|
+
* Also checks that authoritative assertions have evidenceRef and that
|
|
462
|
+
* fact-bearing entities/links have required provenance fields.
|
|
463
|
+
*
|
|
464
|
+
* @param {object} graph — a graphSchema-shaped object
|
|
465
|
+
* @param {{verifyEvidence?: (fact: object, reference: object|string) => boolean}} options
|
|
466
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
467
|
+
*/
|
|
468
|
+
export function validateGraphIntegrity(graph, { verifyEvidence } = {}) {
|
|
469
|
+
const errors = [];
|
|
470
|
+
|
|
471
|
+
// First, validate the full graph against the schema (F4)
|
|
472
|
+
const schemaResult = graphSchema.safeParse(graph);
|
|
473
|
+
if (!schemaResult.success) {
|
|
474
|
+
for (const issue of schemaResult.error.issues) {
|
|
475
|
+
errors.push(`schema: ${issue.path.join(".")}: ${issue.message}`);
|
|
476
|
+
}
|
|
477
|
+
return { valid: false, errors };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const data = schemaResult.data;
|
|
481
|
+
|
|
482
|
+
// Check: coverage.totalAssertions must match actual assertion count
|
|
483
|
+
if (data.coverage && data.coverage.totalAssertions !== data.assertions.length) {
|
|
484
|
+
errors.push(
|
|
485
|
+
`coverage.totalAssertions (${data.coverage.totalAssertions}) does not match actual assertion count (${data.assertions.length})`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// A payload's evidence strings are only a claim; authority requires an
|
|
490
|
+
// independently supplied verifier that checks the fact and reference pair.
|
|
491
|
+
const isVerified = (fact, reference) => {
|
|
492
|
+
if (typeof verifyEvidence !== "function") return false;
|
|
493
|
+
try { return verifyEvidence(fact, reference) === true; } catch { return false; }
|
|
494
|
+
};
|
|
495
|
+
for (let i = 0; i < data.assertions.length; i++) {
|
|
496
|
+
const a = data.assertions[i];
|
|
497
|
+
if (a.provenance === "authoritative" && !isVerified(a, a.evidenceRef)) {
|
|
498
|
+
errors.push(`assertions[${i}]: authoritative provenance requires trusted evidence verification`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Check: fact-bearing entities need sourceVersion and capturedAt
|
|
503
|
+
if (data.entities) {
|
|
504
|
+
for (let i = 0; i < data.entities.length; i++) {
|
|
505
|
+
const e = data.entities[i];
|
|
506
|
+
const hasFacts = e.label !== undefined || (e.attributes && Object.keys(e.attributes).length > 0);
|
|
507
|
+
if (hasFacts && (!e.sourceVersion || !e.capturedAt)) {
|
|
508
|
+
errors.push(`entities[${i}]: entity with factual content requires sourceVersion and capturedAt`);
|
|
509
|
+
}
|
|
510
|
+
if (e.provenance === "authoritative" && !isVerified(e, e.evidenceRef)) {
|
|
511
|
+
errors.push(`entities[${i}]: authoritative entity requires trusted evidence verification`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Check: all links are traceable by schema; authority needs separate proof.
|
|
517
|
+
if (data.links) {
|
|
518
|
+
for (let i = 0; i < data.links.length; i++) {
|
|
519
|
+
const l = data.links[i];
|
|
520
|
+
if (!l.tenantScope) {
|
|
521
|
+
errors.push(`links[${i}]: all links require tenantScope`);
|
|
522
|
+
}
|
|
523
|
+
if (l.provenance === "authoritative" && !isVerified(l, l.evidenceAddress)) {
|
|
524
|
+
errors.push(`links[${i}]: authoritative provenance link requires trusted evidence verification`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Fact payloads need explicit numeric types. A finite list of money-like
|
|
530
|
+
// field names cannot catch new names such as grandTotal or purchase_total.
|
|
531
|
+
function validateFactNumbers(obj, path) {
|
|
532
|
+
if (typeof obj === "number") {
|
|
533
|
+
errors.push(`${path}: untyped numeric fact; use canonical money or an explicit quantity`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (!obj || typeof obj !== "object") return;
|
|
537
|
+
if (moneySchema.safeParse(obj).success) return;
|
|
538
|
+
if (obj.kind === "quantity") {
|
|
539
|
+
if (!quantitySchema.safeParse(obj).success) {
|
|
540
|
+
errors.push(`${path}: invalid explicit quantity`);
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (Array.isArray(obj)) {
|
|
545
|
+
for (let i = 0; i < obj.length; i++) {
|
|
546
|
+
validateFactNumbers(obj[i], `${path}[${i}]`);
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
for (const key of Object.keys(obj)) {
|
|
551
|
+
const val = obj[key];
|
|
552
|
+
const fullPath = `${path}.${key}`;
|
|
553
|
+
if (isMoneyFieldName(key, val)) {
|
|
554
|
+
if (Array.isArray(val) && isPluralMoneyFieldName(key) &&
|
|
555
|
+
val.every((item) => moneySchema.safeParse(item).success)) continue;
|
|
556
|
+
if (typeof val === "object" && val !== null) {
|
|
557
|
+
const moneyResult = moneySchema.safeParse(val);
|
|
558
|
+
if (!moneyResult.success) {
|
|
559
|
+
for (const issue of moneyResult.error.issues) {
|
|
560
|
+
errors.push(`${fullPath}: money field invalid — ${issue.message}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
errors.push(`${fullPath}: money field must be a canonical money object { amount, currency, exponent }, got ${typeof val}`);
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
validateFactNumbers(val, fullPath);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check: assertions with monetary predicates must have moneySchema-valid objects
|
|
573
|
+
for (let i = 0; i < data.assertions.length; i++) {
|
|
574
|
+
const a = data.assertions[i];
|
|
575
|
+
if (isMoneyFieldName(a.predicate, a.object)) {
|
|
576
|
+
const isMoneyList = Array.isArray(a.object) && isPluralMoneyFieldName(a.predicate) &&
|
|
577
|
+
a.object.every((item) => moneySchema.safeParse(item).success);
|
|
578
|
+
if (!isMoneyList && !moneySchema.safeParse(a.object).success) {
|
|
579
|
+
errors.push(`assertions[${i}].object: assertion with monetary predicate "${a.predicate}" must have a canonical money object, got ${typeof a.object === "object" ? JSON.stringify(a.object) : String(a.object)}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Only open-ended fact JSON needs this check. Typed graph metadata has its
|
|
585
|
+
// own schemas, and money objects must not be reinterpreted as bare numbers.
|
|
586
|
+
for (let i = 0; i < (data.entities ?? []).length; i++) {
|
|
587
|
+
if (data.entities[i].attributes) validateFactNumbers(data.entities[i].attributes, `entities[${i}].attributes`);
|
|
588
|
+
}
|
|
589
|
+
for (let i = 0; i < data.assertions.length; i++) {
|
|
590
|
+
validateFactNumbers(data.assertions[i].object, `assertions[${i}].object`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return { valid: errors.length === 0, errors };
|
|
594
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Preserve identity bytes exactly. Trimming at parse time could silently
|
|
4
|
+
// change a tenant or source key; blank or padded identities are invalid.
|
|
5
|
+
export const nonblankIdentitySchema = z.string().min(1).refine(
|
|
6
|
+
(value) => value.trim().length > 0 && value.trim() === value,
|
|
7
|
+
"identity must be nonblank and unpadded",
|
|
8
|
+
);
|