488 lines
18 KiB
JavaScript
488 lines
18 KiB
JavaScript
/**
|
|
* Truth Anchor System
|
|
*
|
|
* Immutable, externally-signed facts that anchor the system to reality
|
|
* beyond tool outputs and internal memory.
|
|
*
|
|
* A Truth Anchor is a fact that:
|
|
* - Is immutable once recorded (cannot be overwritten or decayed)
|
|
* - Is externally signed (by a human, hardware sensor, or external authority)
|
|
* - Supersedes any internal belief that contradicts it
|
|
* - Can be referenced but never mutated
|
|
* - Has a verifiable signature chain
|
|
*
|
|
* TruthAnchorStore:
|
|
* - Creates and signs new truth anchors with HMAC-SHA256
|
|
* - Append-only storage (anchors are never mutated after creation)
|
|
* - Retrieval by ID, time range, kind, attester, or tags
|
|
* - Signature verification for individual anchors or the full store
|
|
* - Supersession chain: new anchors can declare they supersede old ones
|
|
* - Export/import for persistence and transfer
|
|
* - Max 50,000 anchors with LRU eviction of expired ones only
|
|
*
|
|
* TruthResolver:
|
|
* - Resolves conflicts between internal beliefs and truth anchors
|
|
* - Memory conflict resolution (truth anchor always wins)
|
|
* - Decision conflict resolution (constrains proposed actions)
|
|
* - Topic-based ground truth retrieval with fuzzy tag matching
|
|
*
|
|
* @module @claude-flow/guidance/truth-anchors
|
|
*/
|
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
// ============================================================================
|
|
// Default Configuration
|
|
// ============================================================================
|
|
const DEFAULT_CONFIG = {
|
|
signingKey: '',
|
|
maxAnchors: 50_000,
|
|
};
|
|
// ============================================================================
|
|
// Signing Helpers
|
|
// ============================================================================
|
|
/**
|
|
* Compute the canonical string representation of an anchor for signing.
|
|
*
|
|
* Deterministic ordering ensures the same anchor always produces
|
|
* the same signature regardless of property insertion order.
|
|
*/
|
|
function canonicalize(anchor) {
|
|
return [
|
|
anchor.id,
|
|
anchor.kind,
|
|
anchor.claim,
|
|
anchor.evidence,
|
|
anchor.attesterId,
|
|
String(anchor.timestamp),
|
|
String(anchor.validFrom),
|
|
String(anchor.validUntil ?? 'null'),
|
|
anchor.supersedes.join(','),
|
|
anchor.tags.join(','),
|
|
JSON.stringify(anchor.metadata),
|
|
].join('|');
|
|
}
|
|
/**
|
|
* Produce an HMAC-SHA256 signature for the given data using the provided key.
|
|
*/
|
|
function sign(data, key) {
|
|
return createHmac('sha256', key).update(data).digest('hex');
|
|
}
|
|
// ============================================================================
|
|
// Truth Anchor Store
|
|
// ============================================================================
|
|
/**
|
|
* Append-only store for truth anchors.
|
|
*
|
|
* Anchors are immutable once created. The store provides signing,
|
|
* verification, querying, supersession, and capacity management
|
|
* with LRU eviction of expired anchors only.
|
|
*/
|
|
export class TruthAnchorStore {
|
|
config;
|
|
anchors = [];
|
|
indexById = new Map();
|
|
constructor(config = {}) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
if (!this.config.signingKey) {
|
|
throw new Error('TruthAnchorStore requires a signingKey in config. ' +
|
|
'Anchors cannot be created without a signing key.');
|
|
}
|
|
}
|
|
/**
|
|
* Create and sign a new truth anchor.
|
|
*
|
|
* The anchor is appended to the store and can never be mutated.
|
|
* If the store exceeds capacity, expired anchors are evicted
|
|
* starting from the oldest.
|
|
*/
|
|
anchor(params) {
|
|
const now = Date.now();
|
|
const partial = {
|
|
id: randomUUID(),
|
|
kind: params.kind,
|
|
claim: params.claim,
|
|
evidence: params.evidence,
|
|
attesterId: params.attesterId,
|
|
timestamp: now,
|
|
validFrom: params.validFrom ?? now,
|
|
validUntil: params.validUntil ?? null,
|
|
supersedes: params.supersedes ?? [],
|
|
tags: params.tags ?? [],
|
|
metadata: params.metadata ?? {},
|
|
};
|
|
const signature = sign(canonicalize(partial), this.config.signingKey);
|
|
const truthAnchor = {
|
|
...partial,
|
|
signature,
|
|
};
|
|
// Append (never mutate existing entries)
|
|
this.anchors.push(truthAnchor);
|
|
this.indexById.set(truthAnchor.id, this.anchors.length - 1);
|
|
// Enforce capacity by evicting expired anchors
|
|
this.enforceCapacity(now);
|
|
return truthAnchor;
|
|
}
|
|
/**
|
|
* Retrieve a truth anchor by its ID.
|
|
*
|
|
* Returns undefined if the anchor does not exist.
|
|
*/
|
|
get(id) {
|
|
const index = this.indexById.get(id);
|
|
if (index === undefined)
|
|
return undefined;
|
|
return this.anchors[index];
|
|
}
|
|
/**
|
|
* Get all anchors that are valid at the given timestamp.
|
|
*
|
|
* An anchor is active when:
|
|
* - `validFrom <= timestamp`
|
|
* - `validUntil` is null (indefinite) or `validUntil > timestamp`
|
|
*
|
|
* Defaults to the current time if no timestamp is provided.
|
|
*/
|
|
getActive(timestamp) {
|
|
const ts = timestamp ?? Date.now();
|
|
return this.anchors.filter(a => isActive(a, ts));
|
|
}
|
|
/**
|
|
* Query anchors with optional filters.
|
|
*
|
|
* All provided filters are ANDed together. An anchor must match
|
|
* every specified filter to be included in the result.
|
|
*/
|
|
query(opts) {
|
|
return this.anchors.filter(a => {
|
|
if (opts.kind !== undefined && a.kind !== opts.kind)
|
|
return false;
|
|
if (opts.attesterId !== undefined && a.attesterId !== opts.attesterId)
|
|
return false;
|
|
if (opts.tags !== undefined && opts.tags.length > 0) {
|
|
const hasMatch = opts.tags.some(tag => a.tags.includes(tag));
|
|
if (!hasMatch)
|
|
return false;
|
|
}
|
|
if (opts.validAt !== undefined && !isActive(a, opts.validAt))
|
|
return false;
|
|
return true;
|
|
});
|
|
}
|
|
/**
|
|
* Verify the HMAC-SHA256 signature of a single anchor.
|
|
*
|
|
* Recomputes the signature from the anchor's content and compares
|
|
* it to the stored signature. Returns true if they match.
|
|
*/
|
|
verify(id) {
|
|
const anchor = this.get(id);
|
|
if (!anchor)
|
|
return false;
|
|
const { signature, ...rest } = anchor;
|
|
const expected = sign(canonicalize(rest), this.config.signingKey);
|
|
return timingSafeEqual(signature, expected);
|
|
}
|
|
/**
|
|
* Verify all anchors in the store.
|
|
*
|
|
* Returns a summary with the count of valid anchors and the
|
|
* IDs of any anchors whose signatures do not match.
|
|
*/
|
|
verifyAll() {
|
|
let valid = 0;
|
|
const invalid = [];
|
|
for (const anchor of this.anchors) {
|
|
const { signature, ...rest } = anchor;
|
|
const expected = sign(canonicalize(rest), this.config.signingKey);
|
|
if (timingSafeEqual(signature, expected)) {
|
|
valid++;
|
|
}
|
|
else {
|
|
invalid.push(anchor.id);
|
|
}
|
|
}
|
|
return { valid, invalid };
|
|
}
|
|
/**
|
|
* Create a new anchor that supersedes an existing one.
|
|
*
|
|
* The old anchor remains in the store (immutable) but the new
|
|
* anchor's `supersedes` array includes the old anchor's ID.
|
|
* This creates a verifiable supersession chain.
|
|
*
|
|
* Throws if the old anchor ID does not exist.
|
|
*/
|
|
supersede(oldId, params) {
|
|
const old = this.get(oldId);
|
|
if (!old) {
|
|
throw new Error(`Cannot supersede: anchor "${oldId}" not found`);
|
|
}
|
|
const supersedes = [...(params.supersedes ?? [])];
|
|
if (!supersedes.includes(oldId)) {
|
|
supersedes.push(oldId);
|
|
}
|
|
return this.anchor({
|
|
...params,
|
|
supersedes,
|
|
});
|
|
}
|
|
/**
|
|
* Resolve a claim against an internal belief.
|
|
*
|
|
* Searches for active truth anchors whose claim matches the
|
|
* provided claim text. If a matching truth anchor exists and
|
|
* is currently valid, it wins over the internal belief.
|
|
*
|
|
* Returns the truth anchor if it exists, otherwise returns
|
|
* undefined (meaning the internal belief stands).
|
|
*/
|
|
resolve(claim, _internalBelief) {
|
|
const now = Date.now();
|
|
const normalizedClaim = claim.toLowerCase().trim();
|
|
// Find active anchors whose claim matches
|
|
for (const anchor of this.anchors) {
|
|
if (!isActive(anchor, now))
|
|
continue;
|
|
if (anchor.claim.toLowerCase().trim() === normalizedClaim) {
|
|
return anchor;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Export all anchors for persistence or transfer.
|
|
*
|
|
* Returns a shallow copy of the anchor array. The individual
|
|
* anchor objects are returned as-is since they are immutable.
|
|
*/
|
|
exportAnchors() {
|
|
return [...this.anchors];
|
|
}
|
|
/**
|
|
* Import anchors from an external source.
|
|
*
|
|
* Imported anchors are appended to the store. Duplicate IDs
|
|
* (anchors already in the store) are silently skipped.
|
|
* Capacity enforcement runs after import.
|
|
*/
|
|
importAnchors(anchors) {
|
|
const now = Date.now();
|
|
for (const anchor of anchors) {
|
|
// Skip duplicates
|
|
if (this.indexById.has(anchor.id))
|
|
continue;
|
|
this.anchors.push(anchor);
|
|
this.indexById.set(anchor.id, this.anchors.length - 1);
|
|
}
|
|
this.enforceCapacity(now);
|
|
}
|
|
/**
|
|
* Get the total number of anchors in the store.
|
|
*/
|
|
get size() {
|
|
return this.anchors.length;
|
|
}
|
|
// ===== Private =====
|
|
/**
|
|
* Enforce the maximum anchor capacity.
|
|
*
|
|
* Only expired anchors are evicted, starting from the oldest.
|
|
* If no expired anchors can be evicted and the store is still
|
|
* over capacity, the oldest expired anchors are removed first.
|
|
* Active (non-expired) anchors are never evicted.
|
|
*/
|
|
enforceCapacity(now) {
|
|
if (this.anchors.length <= this.config.maxAnchors)
|
|
return;
|
|
// Collect indices of expired anchors (oldest first, array is append-only)
|
|
const expiredIndices = [];
|
|
for (let i = 0; i < this.anchors.length; i++) {
|
|
const a = this.anchors[i];
|
|
if (a.validUntil !== null && a.validUntil <= now) {
|
|
expiredIndices.push(i);
|
|
}
|
|
}
|
|
// Determine how many we need to evict
|
|
const excess = this.anchors.length - this.config.maxAnchors;
|
|
const toEvict = Math.min(excess, expiredIndices.length);
|
|
if (toEvict <= 0)
|
|
return;
|
|
// Build a set of indices to remove (oldest expired first)
|
|
const removeSet = new Set(expiredIndices.slice(0, toEvict));
|
|
// Rebuild the array and index, preserving order
|
|
const surviving = [];
|
|
this.indexById.clear();
|
|
for (let i = 0; i < this.anchors.length; i++) {
|
|
if (removeSet.has(i))
|
|
continue;
|
|
this.indexById.set(this.anchors[i].id, surviving.length);
|
|
surviving.push(this.anchors[i]);
|
|
}
|
|
// Replace contents (splice to preserve the same array reference)
|
|
this.anchors.length = 0;
|
|
for (const a of surviving) {
|
|
this.anchors.push(a);
|
|
}
|
|
}
|
|
}
|
|
// ============================================================================
|
|
// Truth Resolver
|
|
// ============================================================================
|
|
/**
|
|
* Resolves conflicts between internal system beliefs and externally
|
|
* anchored truth.
|
|
*
|
|
* The fundamental principle: truth anchors always win. If a valid,
|
|
* active truth anchor contradicts an internal belief, the anchor
|
|
* takes precedence.
|
|
*/
|
|
export class TruthResolver {
|
|
store;
|
|
constructor(store) {
|
|
this.store = store;
|
|
}
|
|
/**
|
|
* Check if any active truth anchor contradicts a memory value.
|
|
*
|
|
* Searches by namespace and key as tags, and by the memory value
|
|
* as a claim. If a truth anchor exists that covers the same topic,
|
|
* it wins over the internal memory.
|
|
*/
|
|
resolveMemoryConflict(memoryKey, memoryValue, namespace) {
|
|
const now = Date.now();
|
|
const active = this.store.getActive(now);
|
|
// Search for anchors tagged with the namespace or memory key
|
|
const normalizedKey = memoryKey.toLowerCase().trim();
|
|
const normalizedNs = namespace.toLowerCase().trim();
|
|
const normalizedValue = memoryValue.toLowerCase().trim();
|
|
for (const anchor of active) {
|
|
const lowerTags = anchor.tags.map(t => t.toLowerCase());
|
|
// Check if the anchor is relevant to this memory entry
|
|
const tagMatch = lowerTags.includes(normalizedKey) ||
|
|
lowerTags.includes(normalizedNs) ||
|
|
lowerTags.includes(`${normalizedNs}:${normalizedKey}`);
|
|
if (!tagMatch)
|
|
continue;
|
|
// Check if the anchor's claim contradicts the memory value
|
|
const anchorClaim = anchor.claim.toLowerCase().trim();
|
|
if (anchorClaim !== normalizedValue) {
|
|
return {
|
|
truthWins: true,
|
|
anchor,
|
|
reason: `Truth anchor "${anchor.id}" (${anchor.kind}) contradicts memory ` +
|
|
`"${namespace}:${memoryKey}". Anchor claim: "${anchor.claim}" ` +
|
|
`supersedes internal value: "${memoryValue}".`,
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
truthWins: false,
|
|
reason: `No active truth anchor contradicts memory "${namespace}:${memoryKey}". ` +
|
|
`Internal belief stands.`,
|
|
};
|
|
}
|
|
/**
|
|
* Check if any active truth anchor constrains a proposed action.
|
|
*
|
|
* Searches for anchors whose claims or tags relate to the proposed
|
|
* action and its context. Returns a conflict resolution indicating
|
|
* whether the action is constrained.
|
|
*/
|
|
resolveDecisionConflict(proposedAction, context) {
|
|
const now = Date.now();
|
|
const active = this.store.getActive(now);
|
|
const normalizedAction = proposedAction.toLowerCase().trim();
|
|
const contextKeys = Object.keys(context).map(k => k.toLowerCase());
|
|
for (const anchor of active) {
|
|
const lowerTags = anchor.tags.map(t => t.toLowerCase());
|
|
const lowerClaim = anchor.claim.toLowerCase();
|
|
// Check if the anchor is relevant: tags intersect with context keys
|
|
// or the action text appears in the claim
|
|
const tagOverlap = lowerTags.some(t => contextKeys.includes(t) || normalizedAction.includes(t));
|
|
const claimRelevance = lowerClaim.includes(normalizedAction) ||
|
|
normalizedAction.includes(lowerClaim);
|
|
if (!tagOverlap && !claimRelevance)
|
|
continue;
|
|
// The anchor is relevant -- it constrains this action
|
|
return {
|
|
truthWins: true,
|
|
anchor,
|
|
reason: `Truth anchor "${anchor.id}" (${anchor.kind}) constrains the ` +
|
|
`proposed action "${proposedAction}". Anchor claim: "${anchor.claim}". ` +
|
|
`Attested by: "${anchor.attesterId}".`,
|
|
};
|
|
}
|
|
return {
|
|
truthWins: false,
|
|
reason: `No active truth anchor constrains the proposed action ` +
|
|
`"${proposedAction}". Action may proceed.`,
|
|
};
|
|
}
|
|
/**
|
|
* Get all active truth anchors relevant to a topic.
|
|
*
|
|
* Uses fuzzy tag matching: a tag matches the topic if either
|
|
* the tag contains the topic or the topic contains the tag
|
|
* (case-insensitive). Also matches against the claim text.
|
|
*/
|
|
getGroundTruth(topic) {
|
|
const now = Date.now();
|
|
const active = this.store.getActive(now);
|
|
const normalizedTopic = topic.toLowerCase().trim();
|
|
return active.filter(anchor => {
|
|
// Fuzzy tag match
|
|
const tagMatch = anchor.tags.some(tag => {
|
|
const lowerTag = tag.toLowerCase();
|
|
return lowerTag.includes(normalizedTopic) || normalizedTopic.includes(lowerTag);
|
|
});
|
|
// Claim text match
|
|
const claimMatch = anchor.claim.toLowerCase().includes(normalizedTopic);
|
|
return tagMatch || claimMatch;
|
|
});
|
|
}
|
|
}
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
/**
|
|
* Check whether a truth anchor is active at a given timestamp.
|
|
*/
|
|
function isActive(anchor, timestamp) {
|
|
if (anchor.validFrom > timestamp)
|
|
return false;
|
|
if (anchor.validUntil !== null && anchor.validUntil <= timestamp)
|
|
return false;
|
|
return true;
|
|
}
|
|
/**
|
|
* Constant-time string comparison to prevent timing attacks on signatures.
|
|
*
|
|
* Compares two hex strings character by character, accumulating
|
|
* differences without short-circuiting.
|
|
*/
|
|
function timingSafeEqual(a, b) {
|
|
if (a.length !== b.length)
|
|
return false;
|
|
let mismatch = 0;
|
|
for (let i = 0; i < a.length; i++) {
|
|
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
}
|
|
return mismatch === 0;
|
|
}
|
|
// ============================================================================
|
|
// Factory Functions
|
|
// ============================================================================
|
|
/**
|
|
* Create a TruthAnchorStore with the given configuration.
|
|
*
|
|
* @param config - Must include `signingKey`. `maxAnchors` defaults to 50,000.
|
|
*/
|
|
export function createTruthAnchorStore(config) {
|
|
return new TruthAnchorStore(config);
|
|
}
|
|
/**
|
|
* Create a TruthResolver backed by the given store.
|
|
*/
|
|
export function createTruthResolver(store) {
|
|
return new TruthResolver(store);
|
|
}
|
|
//# sourceMappingURL=truth-anchors.js.map
|