mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-17 13:45:34 +02:00
feat: preserve passive scoped coordination receipts from PR 3128
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# Agent-space distance metric & collision avoidance (Layer 4)
|
||||
# Agent-space proximity and advisory prototypes (Layer 4)
|
||||
|
||||
> Status: v0 implemented in `scripts/lib/agent-proximity/`. This is the moat
|
||||
> layer of ECC 2.0 — *spatial deconfliction for multiple agents (and humans)
|
||||
> working the same codebase*, modeled on aircraft collision avoidance (TCAS).
|
||||
> Status: v0 proximity scoring exists in `scripts/lib/agent-proximity/`.
|
||||
> A separate v1 shadow trail records bounded advisories and declared responses.
|
||||
> Current scores and drawings have no calibrated conflict probability or live
|
||||
> conflict-reduction result.
|
||||
|
||||
## The analogy
|
||||
|
||||
@@ -28,16 +29,16 @@ also declare an **intent set** *I_a* of files it is about to touch (look-ahead).
|
||||
|
||||
## 2. Collision is multi-channel (noisy-OR)
|
||||
|
||||
Two agents can collide through several independent channels. Each channel *i*
|
||||
yields a collision probability *r_i ∈ [0,1]*; we combine them as the probability
|
||||
of colliding through **at least one** channel:
|
||||
Several channels contribute bounded heuristic scores *r_i ∈ [0,1]*. The shipped
|
||||
noisy-OR-shaped combination is:
|
||||
|
||||
```
|
||||
R(a,b) = 1 − Π_i ( 1 − ω_i · r_i ) (2)
|
||||
```
|
||||
|
||||
with channel weights *ω_i ∈ [0,1]*. The reported **distance** is the dual
|
||||
*D(a,b) = 1 − R(a,b)*.
|
||||
with channel weights *ω_i ∈ [0,1]*. The reported **distance score** is the dual
|
||||
*D(a,b) = 1 − R(a,b)*. Correlated channels and unvalidated weights mean *R* is
|
||||
not a calibrated probability, and *D* need not satisfy metric axioms.
|
||||
|
||||
### Channel 1 — edit overlap *r_overlap*
|
||||
|
||||
@@ -119,10 +120,9 @@ rate near *τ_TA* can pre-emptively escalate before the protected zone is entere
|
||||
Each file gets a coordinate via a **space-filling embedding of its path** (files
|
||||
sharing a long directory prefix share most of their coordinate), then pulled
|
||||
toward its dependency neighbours by one averaging step. An agent sits at the
|
||||
recency-weighted centroid of its files' coordinates. The result: `‖v_a − v_b‖`
|
||||
tracks the collision risk *R*, so a **3D "where are the agents" view** renders
|
||||
agents as moving points in a file-cloud — you literally watch them crawl toward
|
||||
each other, see the advisory line light up, and watch one steer away.
|
||||
recency-weighted centroid of its files' coordinates. The resulting **3D display**
|
||||
illustrates path and dependency proximity. Its Euclidean separation has not
|
||||
been shown to preserve the risk score or predict collisions.
|
||||
|
||||
`scanAirspace(agents, graph)` returns, in one pass: the non-clear `advisories`
|
||||
(what the trigger layer acts on), the 3D `positions` and `fileCoordinates` (what
|
||||
@@ -133,18 +133,58 @@ the renderer draws), and pairwise `links` with risk (the edges to color).
|
||||
- **Inputs** come from the session/work state: each running session's worktree
|
||||
diff gives its working set *W_a*; the dependency graph is built from the repo
|
||||
(`buildDependencyGraph`).
|
||||
- **Triggers**: the control-pane tick calls `scanAirspace`; a Traffic Advisory
|
||||
injects a "transmit intent" message between the two agents' sessions; a
|
||||
Resolution Advisory tells the lower-priority agent to steer (re-target to a
|
||||
different file/subtree) — the first concrete realization of *just-in-time
|
||||
multi-agent (and multi-human) deconfliction*.
|
||||
- **Triggers**: control-pane prototype code can derive messages from
|
||||
`scanAirspace`. Delivery, acknowledgement, scope authorization and actual
|
||||
conflict resolution are separate evidence. The shadow trail below calls no
|
||||
sink, hook or steer path.
|
||||
- **Board**: advisories surface on the kanban as proximity warnings, extending
|
||||
the agent/human JIT assignment layer already in the control pane.
|
||||
|
||||
## Passive shadow receipt trail (v1)
|
||||
|
||||
`scripts/lib/agent-proximity/shadow-trail.js` accepts two supplied, versioned
|
||||
observations with task/repository/base/scope identity, read/write path sets,
|
||||
completeness and expiry. It computes a local advisory using the shipped
|
||||
three-channel scorer. An exact write/write or write/read path overlap takes
|
||||
precedence over a low score. Incomplete, expired, future or incomparable critical
|
||||
observations produce `unknown`; an absent advisory means only that no advisory
|
||||
was found **within the complete, comparable supplied scope and supplied dependency
|
||||
graph**. The optional graph defaults to empty and may be supplied anew for
|
||||
re-observation; a quiet advisory says nothing about unsupplied dependencies.
|
||||
|
||||
Call `createShadowTrail`, then `appendAcknowledgement` for both observed agents,
|
||||
`appendScopeRevision` for one externally declared revision and its new footprint,
|
||||
and `appendReobservation` with a fresh matching pair. `summarizeShadowTrail`
|
||||
reports the latest scoped advisory, its own advisory-bound ACKs, separate
|
||||
historical acknowledgers and `outcome: unadjudicated`. Both original observations
|
||||
must still be current when the scope-change declaration is recorded.
|
||||
`verifyShadowTrail` checks the bounded local receipt chain after JSON persistence.
|
||||
Each receipt hash binds the trail ID, event ID, time, predecessor hash and
|
||||
canonical payload. The verifier rejects noncanonical persisted payload field
|
||||
and path order before comparing hashes. Append results copy earlier receipts,
|
||||
so changing an earlier returned object cannot change a later returned trail.
|
||||
Returned data can still be mutated; verification detects inconsistent local
|
||||
content. A party able to
|
||||
rewrite and rehash the entire trail can forge a consistent chain. These hashes
|
||||
do not establish trusted identity, permission, delivery, whole-log immutability
|
||||
or an independently verified result.
|
||||
|
||||
The module issues no grants and sends no messages. The scope-change receipt's
|
||||
`authorizationRef` is a caller declaration and is labelled `declared-only`;
|
||||
the actual authority owner must validate it before any real work changes. It
|
||||
records path metadata locally, so a caller must apply its own publication policy.
|
||||
It accepts no memory summary or required-state payload. Approximate summaries
|
||||
belong to the separately owned memory envelope; exact task constraints and
|
||||
authority remain outside this heuristic score. The trail is limited to one
|
||||
two-agent change, so it cannot grant coherent ownership across a three-agent
|
||||
conflict component.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- v0 (done): tree + overlap + dependency channels, noisy-OR risk, TCAS advisories,
|
||||
priority/steer, 3D embedding, full test coverage.
|
||||
- v0 (source present): tree + overlap + dependency channels, noisy-OR-shaped
|
||||
risk score, advisory/priority prototype and display embedding.
|
||||
- v1 shadow (source present): passive two-agent observation/advisory/ACK/scope
|
||||
revision/re-observation receipts; no active steering or measured benefit.
|
||||
- v1: call-graph & symbol read/write channels; intent look-ahead; closure-rate
|
||||
escalation wired to live session diffs.
|
||||
- v2: cross-machine airspace over Tailscale (teammate agents enter the same
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
'use strict';
|
||||
|
||||
// Passive, local event trail. Receipt hashes detect accidental changes to these
|
||||
// bytes; they do not authenticate a producer, authorize a scope or prove delivery.
|
||||
const crypto = require('node:crypto');
|
||||
const { collisionRisk, DEFAULTS } = require('./distance');
|
||||
|
||||
const VERSION = 1;
|
||||
const ZERO_HASH = '0'.repeat(64);
|
||||
const MAX_RECEIPTS = 16;
|
||||
function invalid() { throw new Error('Invalid shadow coordination input.'); }
|
||||
function object(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
||||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) invalid();
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length > 64) invalid();
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== 'string' || !Object.hasOwn(value, key) ||
|
||||
!Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function shape(value, allowed, required = allowed) {
|
||||
const row = object(value);
|
||||
if (Reflect.ownKeys(row).some(key => !allowed.includes(key)) ||
|
||||
required.some(key => !Object.hasOwn(row, key))) invalid();
|
||||
return row;
|
||||
}
|
||||
function array(value, max) {
|
||||
if (!Array.isArray(value) || value.length > max ||
|
||||
Object.getPrototypeOf(value) !== Array.prototype) invalid();
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length + 1 || !keys.includes('length')) invalid();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function identifier(value) {
|
||||
if (typeof value !== 'string' || value.length > 120 ||
|
||||
!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) ||
|
||||
['__proto__', 'constructor', 'prototype'].includes(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
function timestamp(value) {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
|
||||
!Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) invalid();
|
||||
return value;
|
||||
}
|
||||
function path(value) {
|
||||
if (typeof value !== 'string' || !value || value.length > 256 ||
|
||||
value.startsWith('/') || /^[A-Za-z]:/.test(value) || value.includes('\\') ||
|
||||
value.split('/').some(part => !part || ['.', '..', '__proto__', 'constructor', 'prototype'].includes(part)) ||
|
||||
[...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) invalid();
|
||||
return value;
|
||||
}
|
||||
function paths(value, max = 32) {
|
||||
array(value, max);
|
||||
const result = value.map(path);
|
||||
if (new Set(result).size !== result.length) invalid();
|
||||
return result.sort();
|
||||
}
|
||||
function observation(value) {
|
||||
const row = shape(value, ['agentId', 'taskId', 'repoId', 'baseRevision', 'scopeRevision',
|
||||
'footprint', 'readPaths', 'writePaths', 'observedAt', 'expiresAt']);
|
||||
if (typeof row.baseRevision !== 'string' || !/^[a-f0-9]{40}$/.test(row.baseRevision) ||
|
||||
!Number.isSafeInteger(row.scopeRevision) || row.scopeRevision < 1 ||
|
||||
!['complete', 'incomplete'].includes(row.footprint)) invalid();
|
||||
const observedAt = timestamp(row.observedAt);
|
||||
const expiresAt = timestamp(row.expiresAt);
|
||||
if (Date.parse(expiresAt) <= Date.parse(observedAt)) invalid();
|
||||
return { agentId: identifier(row.agentId), taskId: identifier(row.taskId),
|
||||
repoId: identifier(row.repoId), baseRevision: row.baseRevision,
|
||||
scopeRevision: row.scopeRevision, footprint: row.footprint,
|
||||
readPaths: paths(row.readPaths), writePaths: paths(row.writePaths), observedAt, expiresAt };
|
||||
}
|
||||
function pair(value) {
|
||||
array(value, 2);
|
||||
if (value.length !== 2) invalid();
|
||||
const result = value.map(observation).sort((a, b) => a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0);
|
||||
if (result[0].agentId === result[1].agentId) invalid();
|
||||
return result;
|
||||
}
|
||||
function graph(value = { adjacency: {} }) {
|
||||
const row = shape(value, ['adjacency']);
|
||||
const adjacency = object(row.adjacency);
|
||||
const entries = Object.entries(adjacency).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
||||
if (entries.length > 64) invalid();
|
||||
const normalized = Object.create(null);
|
||||
let edgeCount = 0;
|
||||
for (const [name, edges] of entries) {
|
||||
edgeCount += Array.isArray(edges) ? edges.length : 257;
|
||||
if (edgeCount > 256) invalid();
|
||||
normalized[path(name)] = paths(edges, 16);
|
||||
}
|
||||
return { adjacency: normalized };
|
||||
}
|
||||
function sameTask(a, b) {
|
||||
return a.agentId === b.agentId && a.taskId === b.taskId &&
|
||||
a.repoId === b.repoId && a.baseRevision === b.baseRevision;
|
||||
}
|
||||
function intersection(a, b) { return a.some(item => b.includes(item)); }
|
||||
function samePaths(a, b) { return a.length === b.length && a.every((item, index) => item === b[index]); }
|
||||
function directHazard(a, b) {
|
||||
if (intersection(a.writePaths, b.writePaths)) return 'write-write';
|
||||
if (intersection(a.writePaths, b.readPaths) || intersection(b.writePaths, a.readPaths)) return 'write-read';
|
||||
return null;
|
||||
}
|
||||
function advisory(observations, dependencyGraph, at) {
|
||||
const [a, b] = observations;
|
||||
const hazard = directHazard(a, b);
|
||||
const aAll = [...new Set([...a.readPaths, ...a.writePaths])].sort();
|
||||
const bAll = [...new Set([...b.readPaths, ...b.writePaths])].sort();
|
||||
const first = collisionRisk({ files: a.writePaths.map(name => ({ path: name })) },
|
||||
{ files: bAll.map(name => ({ path: name })) }, dependencyGraph).risk;
|
||||
const second = collisionRisk({ files: b.writePaths.map(name => ({ path: name })) },
|
||||
{ files: aAll.map(name => ({ path: name })) }, dependencyGraph).risk;
|
||||
const risk = Math.max(first, second);
|
||||
let reason = null;
|
||||
if (a.repoId !== b.repoId || a.baseRevision !== b.baseRevision) reason = 'incomparable-context';
|
||||
else if (observations.some(item => Date.parse(item.observedAt) > Date.parse(at) ||
|
||||
Date.parse(item.expiresAt) <= Date.parse(at))) reason = 'stale-or-future-observation';
|
||||
else if (observations.some(item => item.footprint === 'incomplete')) reason = 'incomplete-footprint';
|
||||
const state = reason ? 'unknown' : hazard || risk >= DEFAULTS.thresholds.ta
|
||||
? 'advisory' : 'no-advisory-in-scope';
|
||||
return { state, reason, hazard: reason === 'incomparable-context' ? null : hazard,
|
||||
heuristicRisk: risk, scoreKind: 'uncalibrated-three-channel-heuristic',
|
||||
authority: 'observation-only', outcome: 'unadjudicated' };
|
||||
}
|
||||
function encode(value) {
|
||||
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = [];
|
||||
for (let index = 0; index < value.length; index += 1) items.push(encode(value[index]));
|
||||
return `[${items.join(',')}]`;
|
||||
}
|
||||
if (!value || typeof value !== 'object') invalid();
|
||||
const fields = [];
|
||||
for (const key of Object.keys(value)) fields.push(`${JSON.stringify(key)}:${encode(value[key])}`);
|
||||
return `{${fields.join(',')}}`;
|
||||
}
|
||||
function digest(trailId, receipt, payload = receipt.payload) {
|
||||
const encoded = encode([VERSION, trailId, receipt.id, receipt.kind, receipt.at,
|
||||
receipt.previousHash, payload]);
|
||||
return crypto.createHash('sha256').update('ecc.tcas.shadow.v1\n').update(encoded).digest('hex');
|
||||
}
|
||||
function newReceipt(trail, id, kind, at, payload) {
|
||||
if (trail.receipts.length >= MAX_RECEIPTS) invalid();
|
||||
const previousHash = trail.receipts.at(-1)?.hash || ZERO_HASH;
|
||||
const row = { version: VERSION, id: identifier(id), kind, at: timestamp(at), previousHash, payload };
|
||||
return { ...row, hash: digest(trail.trailId, row) };
|
||||
}
|
||||
function extend(trail, id, kind, at, payload) {
|
||||
verifyShadowTrail(trail);
|
||||
const copied = copyTrail(trail);
|
||||
const next = { ...copied, receipts: [...copied.receipts, newReceipt(copied, id, kind, at, payload)] };
|
||||
verifyShadowTrail(next);
|
||||
return next;
|
||||
}
|
||||
function normalizePayload(kind, value) {
|
||||
if (kind === 'observation' || kind === 'reobservation') {
|
||||
const row = shape(value, ['observations', 'graph']);
|
||||
return { observations: pair(row.observations), graph: graph(row.graph) };
|
||||
}
|
||||
if (kind === 'advisory') {
|
||||
const row = shape(value, ['state', 'reason', 'hazard', 'heuristicRisk', 'scoreKind', 'authority', 'outcome']);
|
||||
if (!['unknown', 'advisory', 'no-advisory-in-scope'].includes(row.state) ||
|
||||
![null, 'incomparable-context', 'stale-or-future-observation',
|
||||
'incomplete-footprint'].includes(row.reason) ||
|
||||
![null, 'write-write', 'write-read'].includes(row.hazard) ||
|
||||
!Number.isFinite(row.heuristicRisk) || row.heuristicRisk < 0 || row.heuristicRisk > 1 ||
|
||||
row.scoreKind !== 'uncalibrated-three-channel-heuristic' ||
|
||||
row.authority !== 'observation-only' || row.outcome !== 'unadjudicated') invalid();
|
||||
return { state: row.state, reason: row.reason, hazard: row.hazard,
|
||||
heuristicRisk: row.heuristicRisk, scoreKind: row.scoreKind,
|
||||
authority: row.authority, outcome: row.outcome };
|
||||
}
|
||||
if (kind === 'acknowledgement') {
|
||||
const row = shape(value, ['agentId', 'scopeRevision', 'advisoryId']);
|
||||
if (!Number.isSafeInteger(row.scopeRevision) || row.scopeRevision < 1) invalid();
|
||||
return { agentId: identifier(row.agentId), scopeRevision: row.scopeRevision,
|
||||
advisoryId: identifier(row.advisoryId) };
|
||||
}
|
||||
if (kind === 'scope-revision') {
|
||||
const row = shape(value, ['agentId', 'taskId', 'repoId', 'baseRevision',
|
||||
'previousScopeRevision', 'newScopeRevision', 'newReadPaths', 'newWritePaths',
|
||||
'authorizationRef', 'authority']);
|
||||
if (typeof row.baseRevision !== 'string' || !/^[a-f0-9]{40}$/.test(row.baseRevision) ||
|
||||
!Number.isSafeInteger(row.previousScopeRevision) || row.previousScopeRevision < 1 ||
|
||||
!Number.isSafeInteger(row.newScopeRevision) || row.newScopeRevision <= row.previousScopeRevision ||
|
||||
row.authority !== 'declared-only') invalid();
|
||||
return { agentId: identifier(row.agentId), taskId: identifier(row.taskId),
|
||||
repoId: identifier(row.repoId), baseRevision: row.baseRevision,
|
||||
previousScopeRevision: row.previousScopeRevision, newScopeRevision: row.newScopeRevision,
|
||||
newReadPaths: paths(row.newReadPaths), newWritePaths: paths(row.newWritePaths),
|
||||
authorizationRef: identifier(row.authorizationRef), authority: row.authority };
|
||||
}
|
||||
invalid();
|
||||
}
|
||||
function copyTrail(trail) {
|
||||
return { version: VERSION, trailId: trail.trailId,
|
||||
receipts: trail.receipts.map(receipt => ({ version: receipt.version,
|
||||
id: receipt.id, kind: receipt.kind, at: receipt.at,
|
||||
previousHash: receipt.previousHash, payload: normalizePayload(receipt.kind, receipt.payload),
|
||||
hash: receipt.hash })) };
|
||||
}
|
||||
function transition(state, receipt) {
|
||||
const payload = normalizePayload(receipt.kind, receipt.payload);
|
||||
if (receipt.kind === 'observation') {
|
||||
if (state.observations) invalid();
|
||||
return { ...state, observations: payload.observations, graph: payload.graph, awaitingAdvisory: true };
|
||||
}
|
||||
if (receipt.kind === 'advisory') {
|
||||
if (!state.awaitingAdvisory || !state.observations) invalid();
|
||||
const expected = advisory(state.observations, state.graph, receipt.at);
|
||||
if (encode(payload) !== encode(expected)) invalid();
|
||||
return { ...state, latestAdvisory: expected, advisoryId: receipt.id,
|
||||
awaitingAdvisory: false, acknowledgements: [] };
|
||||
}
|
||||
if (state.awaitingAdvisory || !state.latestAdvisory) invalid();
|
||||
if (receipt.kind === 'acknowledgement') {
|
||||
if (state.latestAdvisory.state !== 'advisory' || state.scopeChange || state.reobserved ||
|
||||
payload.advisoryId !== state.advisoryId ||
|
||||
state.acknowledgements.includes(payload.agentId)) invalid();
|
||||
const member = state.observations.find(item => item.agentId === payload.agentId);
|
||||
if (!member || member.scopeRevision !== payload.scopeRevision ||
|
||||
Date.parse(member.expiresAt) <= Date.parse(receipt.at)) invalid();
|
||||
return { ...state, acknowledgements: [...state.acknowledgements, payload.agentId],
|
||||
acknowledgedBy: [...state.acknowledgedBy, payload.agentId] };
|
||||
}
|
||||
if (receipt.kind === 'scope-revision') {
|
||||
if (state.latestAdvisory.state !== 'advisory' || state.scopeChange ||
|
||||
state.reobserved || state.acknowledgements.length !== 2) invalid();
|
||||
const member = state.observations.find(item => item.agentId === payload.agentId);
|
||||
if (!member || !sameTask(member, payload) ||
|
||||
member.scopeRevision !== payload.previousScopeRevision ||
|
||||
state.observations.some(item => Date.parse(item.expiresAt) <= Date.parse(receipt.at) ||
|
||||
Date.parse(item.observedAt) > Date.parse(receipt.at))) invalid();
|
||||
return { ...state, scopeChange: { ...payload, at: receipt.at } };
|
||||
}
|
||||
if (receipt.kind === 'reobservation') {
|
||||
if (!state.scopeChange || state.reobserved) invalid();
|
||||
for (const member of payload.observations) {
|
||||
const old = state.observations.find(item => item.agentId === member.agentId);
|
||||
if (!old || !sameTask(old, member) ||
|
||||
member.scopeRevision !== (member.agentId === state.scopeChange.agentId
|
||||
? state.scopeChange.newScopeRevision : old.scopeRevision) ||
|
||||
!samePaths(member.readPaths, member.agentId === state.scopeChange.agentId
|
||||
? state.scopeChange.newReadPaths : old.readPaths) ||
|
||||
!samePaths(member.writePaths, member.agentId === state.scopeChange.agentId
|
||||
? state.scopeChange.newWritePaths : old.writePaths) ||
|
||||
Date.parse(member.observedAt) < Date.parse(state.scopeChange.at)) invalid();
|
||||
}
|
||||
return { ...state, observations: payload.observations, graph: payload.graph,
|
||||
reobserved: true, awaitingAdvisory: true };
|
||||
}
|
||||
invalid();
|
||||
}
|
||||
function inspect(trail) {
|
||||
const row = shape(trail, ['version', 'trailId', 'receipts']);
|
||||
if (row.version !== VERSION) invalid();
|
||||
array(row.receipts, MAX_RECEIPTS);
|
||||
if (row.receipts.length < 2) invalid();
|
||||
identifier(row.trailId);
|
||||
let previousHash = ZERO_HASH;
|
||||
let previousAt = -Infinity;
|
||||
let state = { observations: null, awaitingAdvisory: false,
|
||||
acknowledgements: [], acknowledgedBy: [] };
|
||||
const ids = new Set();
|
||||
for (const receipt of row.receipts) {
|
||||
shape(receipt, ['version', 'id', 'kind', 'at', 'previousHash', 'payload', 'hash']);
|
||||
const id = identifier(receipt.id);
|
||||
const at = timestamp(receipt.at);
|
||||
const payload = normalizePayload(receipt.kind, receipt.payload);
|
||||
if (encode(receipt.payload) !== encode(payload)) invalid();
|
||||
if (receipt.version !== VERSION || ids.has(id) || Date.parse(at) < previousAt ||
|
||||
typeof receipt.previousHash !== 'string' || typeof receipt.hash !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(receipt.previousHash) ||
|
||||
!/^[a-f0-9]{64}$/.test(receipt.hash) ||
|
||||
receipt.previousHash !== previousHash || receipt.hash !== digest(row.trailId, receipt, payload)) invalid();
|
||||
state = transition(state, { ...receipt, payload });
|
||||
ids.add(id);
|
||||
previousAt = Date.parse(at);
|
||||
previousHash = receipt.hash;
|
||||
}
|
||||
if (state.awaitingAdvisory) invalid();
|
||||
return state;
|
||||
}
|
||||
function guard(fn) {
|
||||
try { return fn(); } catch { invalid(); }
|
||||
}
|
||||
function verifyShadowTrail(trail) { return guard(() => { inspect(trail); return true; }); }
|
||||
function createShadowTrail(input) {
|
||||
const row = shape(input, ['trailId', 'observations', 'at', 'graph'],
|
||||
['trailId', 'observations', 'at']);
|
||||
const trailId = identifier(row.trailId);
|
||||
const at = timestamp(row.at);
|
||||
const observations = pair(row.observations);
|
||||
const dependencyGraph = graph(row.graph);
|
||||
const blank = { version: VERSION, trailId, receipts: [] };
|
||||
const first = newReceipt(blank, `${trailId}:observation-0`, 'observation', at,
|
||||
{ observations, graph: dependencyGraph });
|
||||
const partial = { ...blank, receipts: [first] };
|
||||
const second = newReceipt(partial, `${trailId}:advisory-0`, 'advisory', at,
|
||||
advisory(observations, dependencyGraph, at));
|
||||
const result = { ...blank, receipts: [first, second] };
|
||||
verifyShadowTrail(result);
|
||||
return result;
|
||||
}
|
||||
function appendAcknowledgement(trail, input) {
|
||||
const row = shape(input, ['id', 'agentId', 'scopeRevision', 'at']);
|
||||
const state = inspect(trail);
|
||||
return extend(trail, row.id, 'acknowledgement', row.at,
|
||||
{ agentId: row.agentId, scopeRevision: row.scopeRevision, advisoryId: state.advisoryId });
|
||||
}
|
||||
function appendScopeRevision(trail, input) {
|
||||
const row = shape(input, ['id', 'agentId', 'previousScopeRevision',
|
||||
'newScopeRevision', 'newReadPaths', 'newWritePaths', 'authorizationRef', 'at']);
|
||||
const state = inspect(trail);
|
||||
const member = state.observations.find(item => item.agentId === row.agentId);
|
||||
if (!member) invalid();
|
||||
return extend(trail, row.id, 'scope-revision', row.at,
|
||||
{ agentId: row.agentId, taskId: member.taskId, repoId: member.repoId,
|
||||
baseRevision: member.baseRevision, previousScopeRevision: row.previousScopeRevision,
|
||||
newScopeRevision: row.newScopeRevision, newReadPaths: paths(row.newReadPaths),
|
||||
newWritePaths: paths(row.newWritePaths), authorizationRef: row.authorizationRef,
|
||||
authority: 'declared-only' });
|
||||
}
|
||||
function appendReobservation(trail, input) {
|
||||
const row = shape(input, ['id', 'observations', 'at', 'graph'], ['id', 'observations', 'at']);
|
||||
const state = inspect(trail);
|
||||
const at = timestamp(row.at);
|
||||
const observations = pair(row.observations);
|
||||
const dependencyGraph = graph(row.graph === undefined ? state.graph : row.graph);
|
||||
if (trail.receipts.length > MAX_RECEIPTS - 2) invalid();
|
||||
const copied = copyTrail(trail);
|
||||
const first = newReceipt(copied, row.id, 'reobservation', at, { observations, graph: dependencyGraph });
|
||||
const partial = { ...copied, receipts: [...copied.receipts, first] };
|
||||
const second = newReceipt(partial, `${row.id}:advisory`, 'advisory', at,
|
||||
advisory(observations, dependencyGraph, at));
|
||||
const result = { ...copied, receipts: [...partial.receipts, second] };
|
||||
verifyShadowTrail(result);
|
||||
return result;
|
||||
}
|
||||
function summarizeShadowTrail(trail) {
|
||||
const state = inspect(trail);
|
||||
return { version: VERSION, mode: 'shadow-read-only', trailId: trail.trailId,
|
||||
receiptCount: trail.receipts.length, latestAdvisory: { ...state.latestAdvisory },
|
||||
latestAdvisoryId: state.advisoryId,
|
||||
latestAdvisoryAcknowledgedBy: [...state.acknowledgements].sort(),
|
||||
historicalAcknowledgedBy: [...new Set(state.acknowledgedBy)].sort(),
|
||||
scopeChange: state.scopeChange ? { ...state.scopeChange } : null,
|
||||
outcome: 'unadjudicated' };
|
||||
}
|
||||
module.exports = {
|
||||
createShadowTrail: input => guard(() => createShadowTrail(input)),
|
||||
appendAcknowledgement: (trail, input) => guard(() => appendAcknowledgement(trail, input)),
|
||||
appendScopeRevision: (trail, input) => guard(() => appendScopeRevision(trail, input)),
|
||||
appendReobservation: (trail, input) => guard(() => appendReobservation(trail, input)),
|
||||
verifyShadowTrail,
|
||||
summarizeShadowTrail: trail => guard(() => summarizeShadowTrail(trail))
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
createShadowTrail, appendAcknowledgement, appendScopeRevision,
|
||||
appendReobservation, verifyShadowTrail, summarizeShadowTrail
|
||||
} = require('../../scripts/lib/agent-proximity/shadow-trail');
|
||||
|
||||
const at = '2026-09-15T12:00:00.000Z';
|
||||
const later = '2026-09-15T12:01:00.000Z';
|
||||
const latest = '2026-09-15T12:02:00.000Z';
|
||||
const baseRevision = 'a'.repeat(40);
|
||||
|
||||
function observation(agentId, overrides = {}) {
|
||||
return {
|
||||
agentId, taskId: `task-${agentId}`, repoId: 'ecc', baseRevision,
|
||||
scopeRevision: 1, footprint: 'complete', readPaths: [],
|
||||
writePaths: ['src/shared.js'], observedAt: '2026-09-15T11:59:00.000Z',
|
||||
expiresAt: '2026-09-15T12:05:00.000Z', ...overrides
|
||||
};
|
||||
}
|
||||
function start(a = observation('a'), b = observation('b'), options = {}) {
|
||||
return createShadowTrail({ trailId: 'shadow-1', observations: [a, b], at, ...options });
|
||||
}
|
||||
function bothAcknowledge(trail) {
|
||||
const first = appendAcknowledgement(trail, { id: 'ack-a', agentId: 'a', scopeRevision: 1, at: later });
|
||||
return appendAcknowledgement(first, { id: 'ack-b', agentId: 'b', scopeRevision: 1, at: later });
|
||||
}
|
||||
|
||||
test('records a copy-on-append passive observation → advisory → ACK → scope revision → re-observation chain', () => {
|
||||
const initial = start();
|
||||
assert.equal(summarizeShadowTrail(initial).latestAdvisory.state, 'advisory');
|
||||
assert.equal(summarizeShadowTrail(initial).latestAdvisory.hazard, 'write-write');
|
||||
const acknowledged = bothAcknowledge(initial);
|
||||
assert.deepEqual(summarizeShadowTrail(acknowledged).latestAdvisoryAcknowledgedBy, ['a', 'b']);
|
||||
const revised = appendScopeRevision(acknowledged, {
|
||||
id: 'scope-b2', agentId: 'b', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'declared-contract-42', at: later
|
||||
});
|
||||
const complete = appendReobservation(revised, {
|
||||
id: 'reobserve-1', at: latest,
|
||||
observations: [observation('a', { writePaths: ['src/shared.js'], observedAt: later }),
|
||||
observation('b', { scopeRevision: 2, writePaths: ['docs/guide.md'], observedAt: later })]
|
||||
});
|
||||
const summary = summarizeShadowTrail(complete);
|
||||
assert.equal(summary.mode, 'shadow-read-only');
|
||||
assert.equal(summary.latestAdvisory.state, 'no-advisory-in-scope');
|
||||
assert.equal(summary.outcome, 'unadjudicated');
|
||||
assert.equal(summary.scopeChange.authority, 'declared-only');
|
||||
assert.deepEqual(summary.historicalAcknowledgedBy, ['a', 'b']);
|
||||
assert.deepEqual(summary.latestAdvisoryAcknowledgedBy, []);
|
||||
assert.equal(verifyShadowTrail(JSON.parse(JSON.stringify(complete))), true);
|
||||
assert.equal(initial.receipts.length, 2);
|
||||
assert.equal(acknowledged.receipts.length, 4);
|
||||
assert.equal(complete.receipts.length, 7);
|
||||
for (let i = 1; i < complete.receipts.length; i += 1) {
|
||||
assert.equal(complete.receipts[i].previousHash, complete.receipts[i - 1].hash);
|
||||
}
|
||||
assert.ok(!JSON.stringify(complete).includes('CANARY_SECRET'));
|
||||
});
|
||||
|
||||
test('read/read is quiet, write/read is a direct hazard, and dependency proximity reuses shipped scorer', () => {
|
||||
const quiet = start(observation('a', { readPaths: ['src/shared.js'], writePaths: [] }),
|
||||
observation('b', { readPaths: ['src/shared.js'], writePaths: [] }));
|
||||
assert.equal(summarizeShadowTrail(quiet).latestAdvisory.state, 'no-advisory-in-scope');
|
||||
const hazard = start(observation('a', { readPaths: ['src/shared.js'], writePaths: [] }),
|
||||
observation('b', { readPaths: [], writePaths: ['src/shared.js'] }));
|
||||
assert.equal(summarizeShadowTrail(hazard).latestAdvisory.hazard, 'write-read');
|
||||
const graph = { adjacency: { 'apps/web/page.js': ['packages/core/util.js'], 'packages/core/util.js': [] } };
|
||||
const coupled = start(observation('a', { writePaths: ['apps/web/page.js'] }),
|
||||
observation('b', { writePaths: ['packages/core/util.js'] }), { graph });
|
||||
assert.equal(summarizeShadowTrail(coupled).latestAdvisory.state, 'advisory');
|
||||
assert.equal(summarizeShadowTrail(coupled).latestAdvisory.hazard, null);
|
||||
});
|
||||
|
||||
test('known overlap cannot be clear, while incomplete, stale and incomparable critical data remain unknown', () => {
|
||||
const partialHazard = start(observation('a', { footprint: 'incomplete' }), observation('b'));
|
||||
assert.equal(summarizeShadowTrail(partialHazard).latestAdvisory.state, 'unknown');
|
||||
assert.equal(summarizeShadowTrail(partialHazard).latestAdvisory.hazard, 'write-write');
|
||||
const partialQuiet = start(observation('a', { footprint: 'incomplete', writePaths: [] }),
|
||||
observation('b', { writePaths: ['docs/guide.md'] }));
|
||||
assert.equal(summarizeShadowTrail(partialQuiet).latestAdvisory.state, 'unknown');
|
||||
const stale = start(observation('a', { expiresAt: '2026-09-15T11:59:30.000Z' }), observation('b'));
|
||||
assert.equal(summarizeShadowTrail(stale).latestAdvisory.state, 'unknown');
|
||||
const foreignBase = start(observation('a'), observation('b', { baseRevision: 'b'.repeat(40) }));
|
||||
assert.equal(summarizeShadowTrail(foreignBase).latestAdvisory.state, 'unknown');
|
||||
});
|
||||
|
||||
test('stale or crossed ACK, unacknowledged scope change and mismatched re-observation fail closed', () => {
|
||||
const initial = start();
|
||||
assert.throws(() => appendAcknowledgement(initial, { id: 'bad-ack', agentId: 'a', scopeRevision: 2, at: later }), /Invalid shadow/);
|
||||
assert.throws(() => appendAcknowledgement(initial, { id: 'bad-ack', agentId: 'c', scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
assert.throws(() => appendScopeRevision(initial, { id: 'premature', agentId: 'b', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'evidence-1', at: later }), /Invalid shadow/);
|
||||
const ack = bothAcknowledge(initial);
|
||||
assert.throws(() => appendAcknowledgement(ack, { id: 'third-ack', agentId: 'a', scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
const revised = appendScopeRevision(ack, { id: 'scope-b2', agentId: 'b', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'evidence-1', at: later });
|
||||
assert.throws(() => appendReobservation(revised, { id: 'wrong-scope', at: latest,
|
||||
observations: [observation('a'), observation('b')] }), /Invalid shadow/);
|
||||
assert.throws(() => appendReobservation(revised, { id: 'foreign-task', at: latest,
|
||||
observations: [observation('a'), observation('b', { scopeRevision: 2, taskId: 'other-task' })] }), /Invalid shadow/);
|
||||
assert.throws(() => appendReobservation(revised, { id: 'different-declaration', at: latest,
|
||||
observations: [observation('a'), observation('b', { scopeRevision: 2,
|
||||
writePaths: ['src/not-declared.js'], observedAt: later })] }), /Invalid shadow/);
|
||||
assert.throws(() => appendReobservation(revised, { id: 'changed-other-agent', at: latest,
|
||||
observations: [observation('a', { writePaths: ['src/changed-without-revision.js'], observedAt: later }),
|
||||
observation('b', { scopeRevision: 2, writePaths: ['docs/guide.md'], observedAt: later })] }), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('unknown critical footprints cannot be used as an acknowledged clear-scoped resolution', () => {
|
||||
const unknown = start(observation('a', { footprint: 'incomplete', writePaths: [] }), observation('b'));
|
||||
assert.throws(() => appendAcknowledgement(unknown, { id: 'ack-unknown', agentId: 'a',
|
||||
scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('receipt hashes bind the trail identity and reject cross-trail relabeling', () => {
|
||||
const relabeled = JSON.parse(JSON.stringify(start()));
|
||||
relabeled.trailId = 'different-shadow-trail';
|
||||
assert.throws(() => verifyShadowTrail(relabeled), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('a persisted receipt with reordered path bytes is noncanonical and rejected', () => {
|
||||
const trail = start(observation('a', { writePaths: ['src/a.js', 'src/b.js'] }),
|
||||
observation('b', { writePaths: ['src/b.js'] }));
|
||||
const reordered = JSON.parse(JSON.stringify(trail));
|
||||
reordered.receipts[0].payload.observations[0].writePaths.reverse();
|
||||
assert.throws(() => verifyShadowTrail(reordered), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('dependency graph insertion order canonicalizes to the same receipt content', () => {
|
||||
const a = start(observation('a'), observation('b'), { graph: { adjacency: {
|
||||
'src/z.js': ['src/a.js'], 'src/a.js': []
|
||||
} } });
|
||||
const b = start(observation('a'), observation('b'), { graph: { adjacency: {
|
||||
'src/a.js': [], 'src/z.js': ['src/a.js']
|
||||
} } });
|
||||
assert.equal(a.receipts[0].hash, b.receipts[0].hash);
|
||||
});
|
||||
|
||||
test('append results keep independent copies of earlier receipt payloads', () => {
|
||||
const initial = start();
|
||||
const appended = appendAcknowledgement(initial, {
|
||||
id: 'copy-ack', agentId: 'a', scopeRevision: 1, at: later
|
||||
});
|
||||
initial.receipts[0].payload.observations[0].writePaths[0] = 'src/changed.js';
|
||||
assert.throws(() => verifyShadowTrail(initial), /Invalid shadow/);
|
||||
assert.equal(verifyShadowTrail(appended), true);
|
||||
});
|
||||
|
||||
test('verification rejects nested serialization hooks, sparse and accessor arrays before execution', () => {
|
||||
const original = start();
|
||||
let callbackRuns = 0;
|
||||
const hooked = JSON.parse(JSON.stringify(original));
|
||||
hooked.receipts[0].payload.observations[0].writePaths.toJSON = () => {
|
||||
callbackRuns += 1;
|
||||
return ['src/shared.js'];
|
||||
};
|
||||
assert.throws(() => verifyShadowTrail(hooked), /Invalid shadow/);
|
||||
assert.equal(callbackRuns, 0);
|
||||
|
||||
const accessor = JSON.parse(JSON.stringify(original));
|
||||
Object.defineProperty(accessor.receipts[0].payload.observations, '0', {
|
||||
enumerable: true, get() { callbackRuns += 1; return observation('a'); }
|
||||
});
|
||||
assert.throws(() => verifyShadowTrail(accessor), /Invalid shadow/);
|
||||
assert.equal(callbackRuns, 0);
|
||||
|
||||
const sparse = JSON.parse(JSON.stringify(original));
|
||||
delete sparse.receipts[0].payload.observations[0].writePaths[0];
|
||||
assert.throws(() => verifyShadowTrail(sparse), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('hash fields reject coercible objects without invoking their conversion hooks', () => {
|
||||
const trail = JSON.parse(JSON.stringify(start()));
|
||||
let callbackRuns = 0;
|
||||
trail.receipts[0].hash = { toString() { callbackRuns += 1; return '0'.repeat(64); } };
|
||||
assert.throws(() => verifyShadowTrail(trail), /Invalid shadow/);
|
||||
assert.equal(callbackRuns, 0);
|
||||
});
|
||||
|
||||
test('receipt verification never invokes inherited JSON serialization hooks', () => {
|
||||
const persisted = JSON.parse(JSON.stringify(start()));
|
||||
let callbackRuns = 0;
|
||||
Object.defineProperty(Array.prototype, 'toJSON', { configurable: true,
|
||||
value() { callbackRuns += 1; return []; } });
|
||||
try {
|
||||
assert.equal(verifyShadowTrail(persisted), true);
|
||||
assert.equal(callbackRuns, 0);
|
||||
} finally {
|
||||
delete Array.prototype.toJSON;
|
||||
}
|
||||
});
|
||||
|
||||
test('exotic verification failures are generic and do not echo private text', () => {
|
||||
const trail = start();
|
||||
const receipts = new Proxy(trail.receipts, {
|
||||
getPrototypeOf() { throw new Error('CANARY_SECRET'); }
|
||||
});
|
||||
assert.throws(() => verifyShadowTrail({ ...trail, receipts }), error =>
|
||||
/Invalid shadow/.test(error.message) && !error.message.includes('CANARY_SECRET'));
|
||||
});
|
||||
|
||||
test('scope revision requires both acknowledged observations to remain current', () => {
|
||||
const initial = start(observation('a'), observation('b', {
|
||||
expiresAt: '2026-09-15T12:01:30.000Z'
|
||||
}));
|
||||
const acknowledged = bothAcknowledge(initial);
|
||||
assert.throws(() => appendScopeRevision(acknowledged, {
|
||||
id: 'scope-after-peer-expiry', agentId: 'a', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'declared-contract-42', at: latest
|
||||
}), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('tampering, duplicate IDs, unknown fields and unbounded input cannot be accepted or echoed', () => {
|
||||
const initial = start();
|
||||
const tampered = JSON.parse(JSON.stringify(initial));
|
||||
tampered.receipts[0].payload.observations[0].writePaths = ['CANARY_SECRET'];
|
||||
assert.throws(() => verifyShadowTrail(tampered), /Invalid shadow/);
|
||||
const poisoned = observation('a', { summary: 'CANARY_SECRET' });
|
||||
assert.throws(() => start(poisoned, observation('b')), error =>
|
||||
/Invalid shadow/.test(error.message) && !error.message.includes('CANARY_SECRET'));
|
||||
assert.throws(() => start(observation('a', { writePaths: ['../escape'] }), observation('b')), /Invalid shadow/);
|
||||
assert.throws(() => start(observation('a', { writePaths: Array(33).fill('src/a.js') }), observation('b')), /Invalid shadow/);
|
||||
assert.throws(() => start(observation('a'), observation('b'), { graph: { adjacency: { '__proto__': ['src/a.js'] } } }), /Invalid shadow/);
|
||||
assert.throws(() => appendAcknowledgement(initial, { id: initial.receipts[0].id, agentId: 'a',
|
||||
scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
});
|
||||
Reference in New Issue
Block a user