Standalone per-card browser model/security/view modules for HUX-01..10 plus node+pytest suites that read the hux.v1 contract schemas directly. Reconciled drift found on integration: the activity model now accepts all 32 hux.event.v1 kinds (delegation.*, memory.suppressed, memory.retrieval_removed, budget.exhausted, side_effect.*), the autonomy model carries the external_side_effect capability, and the foundation boundary test now asserts the shipped static HUX surface exists on disk and that images never bake activated HUX_FLAGS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
240 lines
7.1 KiB
TypeScript
240 lines
7.1 KiB
TypeScript
/** Fail-closed normalization for the append-only HUX-02 memory ledger. */
|
|
|
|
import {
|
|
MemoryContractError,
|
|
isOpaqueMemoryId,
|
|
isUtcTimestamp,
|
|
normalizeIdentity,
|
|
safeMemoryText,
|
|
sameIdentity,
|
|
} from "./security.ts";
|
|
import type {
|
|
HuxIdentity,
|
|
MemoryItem,
|
|
MemoryKind,
|
|
MemoryPage,
|
|
MemoryStatus,
|
|
MemoryTopic,
|
|
RawMemoryEntry,
|
|
RawMemoryPage,
|
|
Sensitivity,
|
|
} from "./types.ts";
|
|
|
|
export const FOUNDATION_FLAG = "hux.foundation";
|
|
export const PRIVACY_FLAG = "hux.privacy";
|
|
export const MEMORY_CONTROL_FLAG = "hux.memory_control";
|
|
|
|
const KINDS = new Set<MemoryKind>([
|
|
"preference",
|
|
"fact",
|
|
"instruction",
|
|
"context",
|
|
]);
|
|
const STATUSES = new Set<MemoryStatus>([
|
|
"proposed",
|
|
"active",
|
|
"rejected",
|
|
"expired",
|
|
"forgotten",
|
|
]);
|
|
const SENSITIVITIES = new Set<Sensitivity>([
|
|
"public",
|
|
"personal",
|
|
"sensitive",
|
|
"restricted",
|
|
]);
|
|
export const MEMORY_TOPICS = [
|
|
"general",
|
|
"health",
|
|
"finance",
|
|
"legal",
|
|
"relationships",
|
|
"credentials",
|
|
"minors",
|
|
"location",
|
|
"biometric",
|
|
] as const satisfies readonly MemoryTopic[];
|
|
const TOPICS = new Set<MemoryTopic>(MEMORY_TOPICS);
|
|
const SOURCE_LABELS: Readonly<Record<string, string>> = {
|
|
message: "Source message",
|
|
tool_call: "Tool request",
|
|
tool_result: "Tool evidence",
|
|
artifact_version: "Artifact version",
|
|
source: "Research source",
|
|
passage: "Supporting passage",
|
|
memory: "Earlier memory entry",
|
|
approval: "Approval decision",
|
|
run: "Agent run",
|
|
url: "Website",
|
|
file: "Workspace file",
|
|
build: "Build evidence",
|
|
flux: "Flux reconciliation",
|
|
pod: "Workload evidence",
|
|
};
|
|
const MAX_ENTRIES = 200;
|
|
|
|
export function memoryControlEnabled(flags?: Iterable<string>): boolean {
|
|
if (!flags) return false;
|
|
const active = new Set(flags);
|
|
return (
|
|
active.has(FOUNDATION_FLAG) &&
|
|
active.has(PRIVACY_FLAG) &&
|
|
active.has(MEMORY_CONTROL_FLAG)
|
|
);
|
|
}
|
|
|
|
function ttlLabel(raw: unknown): string | null {
|
|
const ttl = raw as Record<string, unknown> | null;
|
|
if (!ttl || Array.isArray(ttl)) return null;
|
|
if (ttl.policy === "never") return "Kept until you forget it";
|
|
if (ttl.policy === "expires_at" && isUtcTimestamp(ttl.expires_at)) {
|
|
return `Expires ${ttl.expires_at}`;
|
|
}
|
|
if (
|
|
ttl.policy === "decay" &&
|
|
Number.isInteger(ttl.decay_days) &&
|
|
(ttl.decay_days as number) >= 1 &&
|
|
(ttl.decay_days as number) <= 3650
|
|
) {
|
|
return `Reviewed after ${ttl.decay_days} days`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function scopeLabel(raw: unknown): string | null {
|
|
const scope = raw as Record<string, unknown> | null;
|
|
if (!scope || Array.isArray(scope)) return null;
|
|
if (scope.level === "global" && scope.scope_id === undefined)
|
|
return "All conversations";
|
|
if (scope.level === "project" && typeof scope.scope_id === "string")
|
|
return "This project";
|
|
if (scope.level === "conversation" && typeof scope.scope_id === "string")
|
|
return "This conversation";
|
|
return null;
|
|
}
|
|
|
|
function reasonFor(raw: RawMemoryEntry, hidden: boolean): string {
|
|
if (hidden) return "Reason hidden by privacy controls";
|
|
const audit = Array.isArray(raw.audit) ? raw.audit : [];
|
|
for (let index = audit.length - 1; index >= 0; index -= 1) {
|
|
const record = audit[index] as Record<string, unknown> | null;
|
|
if (record?.note) return safeMemoryText(record.note, "Saved for future help", 200);
|
|
}
|
|
return raw.status === "proposed"
|
|
? "Hermes suggested this for your approval"
|
|
: "Saved for future help";
|
|
}
|
|
|
|
function normalizeEntry(
|
|
raw: RawMemoryEntry,
|
|
expected: HuxIdentity,
|
|
): MemoryItem | null {
|
|
if (
|
|
raw.schema !== "hux.memory.v1" ||
|
|
!isOpaqueMemoryId(raw.id) ||
|
|
raw.owner !== expected.userRef ||
|
|
!KINDS.has(raw.kind as MemoryKind) ||
|
|
!STATUSES.has(raw.status as MemoryStatus) ||
|
|
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) ||
|
|
!TOPICS.has((raw.topic ?? "general") as MemoryTopic) ||
|
|
!["automatic", "ask"].includes(String(raw.approval_mode)) ||
|
|
typeof raw.content !== "string" ||
|
|
raw.content.length > 2000 ||
|
|
!Array.isArray(raw.audit) ||
|
|
raw.audit.length < 1 ||
|
|
!isUtcTimestamp(raw.created_at) ||
|
|
!isUtcTimestamp(raw.updated_at)
|
|
) {
|
|
return null;
|
|
}
|
|
const provenance = raw.provenance as Record<string, unknown> | null;
|
|
const source = raw.source as Record<string, unknown> | null;
|
|
const ttl = ttlLabel(raw.ttl);
|
|
const scope = scopeLabel(raw.scope);
|
|
if (
|
|
!provenance ||
|
|
provenance.surface !== expected.surface ||
|
|
!isUtcTimestamp(provenance.recorded_at) ||
|
|
!source ||
|
|
typeof source.id !== "string" ||
|
|
source.id.length < 1 ||
|
|
source.id.length > 200 ||
|
|
typeof source.kind !== "string" ||
|
|
!(source.kind in SOURCE_LABELS) ||
|
|
!ttl ||
|
|
!scope
|
|
) {
|
|
return null;
|
|
}
|
|
const actor = provenance.actor as Record<string, unknown> | null;
|
|
if (
|
|
!actor ||
|
|
!["user", "assistant", "tool", "system", "operator"].includes(String(actor.type)) ||
|
|
typeof actor.id !== "string" ||
|
|
actor.id.length < 1 ||
|
|
actor.id.length > 120
|
|
)
|
|
return null;
|
|
const status = raw.status as MemoryStatus;
|
|
const sensitivity = raw.sensitivity as Sensitivity;
|
|
const hidden =
|
|
status === "forgotten" ||
|
|
sensitivity === "sensitive" ||
|
|
sensitivity === "restricted";
|
|
return {
|
|
id: raw.id,
|
|
kind: raw.kind as MemoryKind,
|
|
content: hidden ? null : safeMemoryText(raw.content, "Memory content unavailable", 2000),
|
|
contentNotice:
|
|
status === "forgotten"
|
|
? "Content permanently removed"
|
|
: hidden
|
|
? "Sensitive value hidden by privacy controls"
|
|
: null,
|
|
status,
|
|
sensitivity,
|
|
topic: (raw.topic ?? "general") as MemoryTopic,
|
|
approvalMode: raw.approval_mode as "automatic" | "ask",
|
|
scopeLabel: scope,
|
|
sourceLabel: SOURCE_LABELS[source.kind],
|
|
sourceIsMessage: source.kind === "message",
|
|
provenanceLabel: `${String(actor.type)} via ${expected.surface}`,
|
|
reason: reasonFor(raw, hidden),
|
|
savedAt: raw.created_at,
|
|
updatedAt: raw.updated_at,
|
|
ttlLabel: ttl,
|
|
};
|
|
}
|
|
|
|
export function normalizeMemoryPage(
|
|
raw: RawMemoryPage,
|
|
expectedIdentity: HuxIdentity,
|
|
): MemoryPage {
|
|
const expected = normalizeIdentity(expectedIdentity);
|
|
if (!raw || typeof raw !== "object" || raw.schema !== "hux.memory_page.v1") {
|
|
throw new MemoryContractError("Memory response version is unsupported");
|
|
}
|
|
const actual = normalizeIdentity(raw.identity);
|
|
if (!sameIdentity(actual, expected)) {
|
|
throw new MemoryContractError("Memory response crossed a scope boundary");
|
|
}
|
|
if (!Array.isArray(raw.entries)) {
|
|
throw new MemoryContractError("Memory ledger entries are missing");
|
|
}
|
|
const records = raw.entries;
|
|
const normalized = records
|
|
.map((entry) => normalizeEntry(entry as RawMemoryEntry, expected))
|
|
.filter((entry): entry is MemoryItem => entry !== null)
|
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
const truncated = Math.max(0, normalized.length - MAX_ENTRIES);
|
|
return {
|
|
entries: truncated ? normalized.slice(0, MAX_ENTRIES) : normalized,
|
|
invalid: records.length - normalized.length,
|
|
truncated,
|
|
nextCursor:
|
|
typeof raw.next_cursor === "string" && raw.next_cursor.length <= 200
|
|
? raw.next_cursor
|
|
: null,
|
|
};
|
|
}
|