hermes(webui): add HUX card UI models with contract-locked suites

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
This commit is contained in:
jenkins 2026-08-24 04:12:03 -03:00
parent 47165274f0
commit b66c762f5d
85 changed files with 12439 additions and 0 deletions

View File

@ -0,0 +1,260 @@
/** Compact, accessible HUX-01 activity timeline. The feature is default-off. */
import {
Activity,
AlertTriangle,
Check,
GitBranch,
ListChecks,
LoaderCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
activityTimelineEnabled,
mergeActivityEvents,
normalizeCancellationReceipt,
surfacePolicy,
} from "./model.ts";
import type {
ActivityItem,
CancellationReceipt,
HuxSurface,
RawActivityEvent,
RawCancellationReceipt,
} from "./types.ts";
interface ActivityTimelineProps {
conversationId: string;
events: readonly RawActivityEvent[];
flags?: Iterable<string>;
surface: HuxSurface;
status?: "connecting" | "live" | "reconnecting" | "complete";
cancellationReceipts?: readonly RawCancellationReceipt[];
onRetry?: () => void;
}
const PHASE_LABELS: Readonly<Record<ActivityItem["phase"], string>> = {
intent: "Intent",
action: "Action",
evidence: "Evidence",
delegation: "Delegation",
decision: "Decision",
completion: "Complete",
failure: "Failed",
};
function PhaseIcon({ item }: { item: ActivityItem }) {
if (item.phase === "failure")
return <AlertTriangle className="h-3.5 w-3.5" aria-hidden />;
if (item.phase === "completion")
return <Check className="h-3.5 w-3.5" aria-hidden />;
if (item.phase === "delegation")
return <GitBranch className="h-3.5 w-3.5" aria-hidden />;
if (item.phase === "decision")
return <ListChecks className="h-3.5 w-3.5" aria-hidden />;
return <Activity className="h-3.5 w-3.5" aria-hidden />;
}
function TimelineRow({
item,
expanded,
}: {
item: ActivityItem;
expanded: boolean;
}) {
return (
<li
className={`hux-activity-row hux-tone-${item.tone}`}
data-kind={item.kind}
data-seq={item.seq}
>
<div className="hux-activity-marker">
<PhaseIcon item={item} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<span className="text-[0.6875rem] font-semibold uppercase tracking-[0.12em] text-current/65">
{PHASE_LABELS[item.phase]}
</span>
<time
className="text-[0.625rem] text-current/45"
dateTime={item.timestamp}
>
{new Date(item.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
})}
</time>
</div>
<p className="mt-0.5 text-xs leading-relaxed text-current/90">
{item.summary}
</p>
{expanded && item.evidence.length > 0 && (
<ul
className="mt-1.5 flex flex-wrap gap-1"
aria-label="Bounded evidence references"
>
{item.evidence.map((evidence, index) => (
<li
key={`${evidence.kind}-${index}`}
className="hux-evidence-chip"
>
{evidence.label}
</li>
))}
</ul>
)}
</div>
</li>
);
}
function CancellationCard({ receipt }: { receipt: CancellationReceipt }) {
const complete = receipt.outcome !== "failed_to_cancel";
return (
<aside
className="hux-cancellation-receipt"
aria-label="Cancellation receipt"
>
<div className="flex items-center gap-2 text-xs font-medium">
{complete ? (
<Check className="h-3.5 w-3.5" aria-hidden />
) : (
<AlertTriangle className="h-3.5 w-3.5" aria-hidden />
)}
<span>
{complete
? "Stop request handled"
: "Hermes could not confirm the stop"}
</span>
</div>
{receipt.sideEffects.length > 0 && (
<ul className="mt-1 list-inside list-disc text-[0.6875rem] text-current/70">
{receipt.sideEffects.map((effect, index) => (
<li key={index}>
{effect.description} —{" "}
{effect.reverted ? "reverted" : "left in place"}
</li>
))}
</ul>
)}
{receipt.omittedEffects > 0 && (
<p className="mt-1 text-[0.625rem] text-current/55">
{receipt.omittedEffects} additional side effect
{receipt.omittedEffects === 1 ? "" : "s"} recorded.
</p>
)}
</aside>
);
}
/** Render nothing unless both HUX-11 and HUX-01 have been explicitly enabled. */
export function ActivityTimeline({
conversationId,
events,
flags,
surface,
status = "live",
cancellationReceipts = [],
onRetry,
}: ActivityTimelineProps) {
const [showAll, setShowAll] = useState(false);
const enabled = activityTimelineEnabled(flags);
const policy = surfacePolicy(surface);
const result = useMemo(
() => mergeActivityEvents([], events, conversationId, surface),
[conversationId, events, surface],
);
const receipts = useMemo(
() =>
cancellationReceipts
.map((receipt) => normalizeCancellationReceipt(receipt, surface))
.filter((receipt): receipt is CancellationReceipt => receipt !== null)
.slice(-3),
[cancellationReceipts, surface],
);
if (!enabled) return null;
const hidden = Math.max(0, result.items.length - policy.initialItems);
const visible = showAll
? result.items
: result.items.slice(-policy.initialItems);
const reconnecting = status === "connecting" || status === "reconnecting";
return (
<section
className={`hux-activity hux-activity-${surface}`}
aria-label="Hermes activity timeline"
>
<header className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">What Hermes is doing</h2>
<p className="text-[0.6875rem] text-current/55">
{reconnecting
? "Reconnecting to durable activity…"
: `${result.items.length} recorded update${result.items.length === 1 ? "" : "s"}`}
</p>
</div>
{reconnecting && (
<LoaderCircle className="h-4 w-4 animate-spin" aria-hidden />
)}
</header>
{(result.conflicts > 0 || result.invalid > 0) && (
<div className="hux-activity-warning" role="status">
Some unsafe or conflicting activity was ignored. Visible history was
not rewritten.
</div>
)}
<ol
className="mt-3 space-y-1.5"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-busy={reconnecting}
>
{visible.map((item) => (
<TimelineRow
key={item.id}
item={item}
expanded={policy.expandRoutineEvents || !item.routine}
/>
))}
</ol>
{result.items.length === 0 && (
<p className="mt-3 text-xs text-current/55">
No activity has been recorded yet.
</p>
)}
{hidden > 0 && !showAll && (
<button
className="hux-activity-more"
type="button"
onClick={() => setShowAll(true)}
>
Show {hidden} earlier update{hidden === 1 ? "" : "s"}
</button>
)}
{showAll && hidden > 0 && (
<button
className="hux-activity-more"
type="button"
onClick={() => setShowAll(false)}
>
Show recent activity only
</button>
)}
{reconnecting && onRetry && (
<button className="hux-activity-more" type="button" onClick={onRetry}>
Retry now
</button>
)}
{receipts.map((receipt) => (
<CancellationCard key={receipt.id} receipt={receipt} />
))}
</section>
);
}

View File

@ -0,0 +1,16 @@
export { ActivityTimeline } from "./ActivityTimeline.tsx";
export {
ACTIVITY_TIMELINE_FLAG,
FOUNDATION_FLAG,
activityTimelineEnabled,
mergeActivityEvents,
normalizeCancellationReceipt,
surfacePolicy,
} from "./model.ts";
export type {
ActivityItem,
CancellationReceipt,
HuxSurface,
RawActivityEvent,
RawCancellationReceipt,
} from "./types.ts";

View File

@ -0,0 +1,306 @@
/** Normalization, replay safety, and surface policy for HUX-01. */
import {
isOpaqueId,
isRfc3339Utc,
safeEvidenceLabel,
safeText,
} from "./security.ts";
import type {
ActivityItem,
ActivityPhase,
ActivityTone,
CancellationReceipt,
HuxSurface,
MergeResult,
RawActivityEvent,
RawCancellationReceipt,
SurfacePolicy,
} from "./types.ts";
export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline";
export const FOUNDATION_FLAG = "hux.foundation";
const EVENT_KINDS = new Set([
"message.user",
"message.assistant",
"decision.route",
"decision.plan",
"tool.call",
"tool.result",
"approval.requested",
"approval.resolved",
"memory.proposed",
"memory.committed",
"memory.forgotten",
"artifact.created",
"artifact.version",
"artifact.promoted",
"citation.attached",
"mode.changed",
"run.started",
"run.cancelled",
"run.completed",
"run.failed",
"privacy.notice",
"suggestion.shown",
"suggestion.dismissed",
"release.transition",
"delegation.started",
"delegation.completed",
"delegation.failed",
"memory.suppressed",
"memory.retrieval_removed",
"budget.exhausted",
"side_effect.blocked",
"side_effect.released",
]);
const SURFACE_POLICIES: Readonly<Record<HuxSurface, SurfacePolicy>> = {
chat: {
maxItems: 80,
initialItems: 12,
evidenceLimit: 3,
cancellationEffectLimit: 4,
expandRoutineEvents: false,
},
worker: {
maxItems: 250,
initialItems: 40,
evidenceLimit: 8,
cancellationEffectLimit: 12,
expandRoutineEvents: true,
},
telegram: {
maxItems: 40,
initialItems: 8,
evidenceLimit: 2,
cancellationEffectLimit: 3,
expandRoutineEvents: false,
},
voice: {
maxItems: 24,
initialItems: 6,
evidenceLimit: 1,
cancellationEffectLimit: 2,
expandRoutineEvents: false,
},
api: {
maxItems: 250,
initialItems: 40,
evidenceLimit: 8,
cancellationEffectLimit: 12,
expandRoutineEvents: true,
},
};
export function surfacePolicy(surface: HuxSurface): SurfacePolicy {
return { ...SURFACE_POLICIES[surface] };
}
export function activityTimelineEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return enabled.has(FOUNDATION_FLAG) && enabled.has(ACTIVITY_TIMELINE_FLAG);
}
function phaseFor(kind: string, hasParent: boolean): ActivityPhase {
if (kind === "message.user") return "intent";
if (
kind === "run.failed" ||
kind === "delegation.failed" ||
kind === "budget.exhausted"
) {
return "failure";
}
if (
kind === "run.completed" ||
kind === "run.cancelled" ||
kind === "message.assistant" ||
kind === "delegation.completed"
) {
return "completion";
}
if (kind === "delegation.started") return "delegation";
if (
kind.startsWith("decision.") ||
kind.startsWith("approval.") ||
kind === "mode.changed" ||
kind === "privacy.notice" ||
kind === "memory.suppressed" ||
kind === "memory.retrieval_removed" ||
kind === "side_effect.blocked"
)
return "decision";
if (
kind === "tool.result" ||
kind === "citation.attached" ||
kind === "artifact.version"
) {
return "evidence";
}
if (kind === "run.started" && hasParent) return "delegation";
return "action";
}
function toneFor(phase: ActivityPhase, kind: string): ActivityTone {
if (phase === "failure") return "warning";
if (phase === "completion") return "success";
if (kind === "approval.requested") return "warning";
if (phase === "action" || phase === "delegation") return "active";
return "neutral";
}
function hiddenSummary(kind: string): string {
if (kind === "tool.call") return "Hermes used a tool";
if (kind === "tool.result") return "Tool evidence was recorded";
if (kind === "run.failed")
return "The run failed; sensitive detail is hidden";
if (kind === "run.completed") return "The run completed";
return "Activity recorded; sensitive detail is hidden";
}
function normalizeEvent(
raw: RawActivityEvent,
conversationId: string,
policy: SurfacePolicy,
): ActivityItem | null {
if (raw.schema !== "hux.event.v1" || !isOpaqueId(raw.id, "evt")) return null;
if (!Number.isInteger(raw.seq) || (raw.seq as number) < 0) return null;
if (!isRfc3339Utc(raw.ts) || raw.conversation_id !== conversationId)
return null;
if (typeof raw.kind !== "string" || !EVENT_KINDS.has(raw.kind)) return null;
const redaction = raw.redaction as { level?: unknown } | null;
const fullyHidden =
raw.sensitivity === "restricted" || redaction?.level === "full";
const phase = phaseFor(raw.kind, typeof raw.parent_event_id === "string");
const references = Array.isArray(raw.evidence) ? raw.evidence : [];
const evidence = references
.map((item) => safeEvidenceLabel((item as { kind?: unknown } | null)?.kind))
.filter((item): item is NonNullable<typeof item> => item !== null)
.slice(0, policy.evidenceLimit);
return {
id: raw.id,
seq: raw.seq as number,
timestamp: raw.ts,
conversationId,
runId:
typeof raw.run_id === "string"
? safeText(raw.run_id, "", 120) || undefined
: undefined,
kind: raw.kind,
phase,
tone: toneFor(phase, raw.kind),
summary: fullyHidden
? hiddenSummary(raw.kind)
: safeText(raw.summary, hiddenSummary(raw.kind)),
evidence,
routine: phase === "action" || phase === "evidence",
};
}
/**
* Merge reconnect pages without duplicates. Existing sequence owners win;
* conflicting replay entries are rejected instead of rewriting visible history.
*/
export function mergeActivityEvents(
existing: readonly ActivityItem[],
incoming: readonly RawActivityEvent[],
conversationId: string,
surface: HuxSurface,
): MergeResult {
const policy = surfacePolicy(surface);
const byId = new Map(existing.map((item) => [item.id, item]));
const bySeq = new Map(existing.map((item) => [item.seq, item]));
let invalid = 0;
let replays = 0;
let conflicts = 0;
const normalized: ActivityItem[] = [];
for (const raw of incoming) {
const item = normalizeEvent(raw, conversationId, policy);
if (!item) {
invalid += 1;
continue;
}
normalized.push(item);
}
// A reconnect page is not required to preserve transport order. Sorting
// before conflict resolution makes an initial replay deterministic.
normalized.sort(
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
);
for (const item of normalized) {
const priorId = byId.get(item.id);
if (priorId) {
if (priorId.seq === item.seq) replays += 1;
else conflicts += 1;
continue;
}
if (bySeq.has(item.seq)) {
conflicts += 1;
continue;
}
byId.set(item.id, item);
bySeq.set(item.seq, item);
}
const ordered = [...byId.values()].sort(
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
);
const afterSeq = ordered.length ? ordered[ordered.length - 1].seq : -1;
const truncated = Math.max(0, ordered.length - policy.maxItems);
return {
items: truncated ? ordered.slice(-policy.maxItems) : ordered,
afterSeq,
invalid,
replays,
conflicts,
truncated,
};
}
export function normalizeCancellationReceipt(
raw: RawCancellationReceipt,
surface: HuxSurface,
): CancellationReceipt | null {
const outcomes = new Set([
"cancelled",
"already_complete",
"failed_to_cancel",
]);
if (raw.schema !== "hux.cancel_receipt.v1" || !isOpaqueId(raw.id, "rcpt"))
return null;
if (typeof raw.run_id !== "string" || !isRfc3339Utc(raw.requested_at))
return null;
if (typeof raw.outcome !== "string" || !outcomes.has(raw.outcome))
return null;
const policy = surfacePolicy(surface);
const effects = Array.isArray(raw.side_effects) ? raw.side_effects : [];
const sideEffects = effects
.slice(0, policy.cancellationEffectLimit)
.map((value) => {
const effect = value as {
description?: unknown;
reverted?: unknown;
} | null;
return {
description: safeText(
effect?.description,
"A side effect was recorded",
280,
),
reverted: effect?.reverted === true,
};
});
return {
id: raw.id,
runId: safeText(raw.run_id, "unknown run", 120),
requestedAt: raw.requested_at,
acknowledgedAt: isRfc3339Utc(raw.acknowledged_at)
? raw.acknowledged_at
: undefined,
completedAt: isRfc3339Utc(raw.completed_at) ? raw.completed_at : undefined,
outcome: raw.outcome as CancellationReceipt["outcome"],
sideEffects,
omittedEffects: Math.max(0, effects.length - sideEffects.length),
};
}

View File

@ -0,0 +1,76 @@
/** Display-only redaction. Raw event payloads must never cross this boundary. */
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
const PEM_BLOCK = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi;
const AUTH_VALUE =
/\b(password|passwd|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
const CREDENTIAL_BLOB =
/\b(?=[A-Za-z0-9_+/#.-]{40,}\b)(?=[A-Za-z0-9_+/#.-]*[A-Za-z])(?=[A-Za-z0-9_+/#.-]*\d)[A-Za-z0-9_+/#.-]+\b/g;
const URI_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
/** Return bounded plain text or a safe fallback; objects are never stringified. */
export function safeText(
value: unknown,
fallback: string,
maxLength = 280,
): string {
if (typeof value !== "string") return fallback;
const limit = Math.max(1, maxLength);
const redacted = value
.replace(PEM_BLOCK, "[redacted credential]")
.replace(AUTH_VALUE, "$1=[redacted]")
.replace(BEARER, "Bearer [redacted]")
.replace(JWT, "[redacted credential]")
.replace(URI_USERINFO, "$1[redacted]@")
.replace(CREDENTIAL_BLOB, "[redacted credential]")
.replace(CONTROL_CHARACTERS, " ")
.replace(/\s+/g, " ")
.trim();
if (!redacted) return fallback;
return redacted.length <= limit
? redacted
: limit === 1
? "…"
: `${redacted.slice(0, limit - 1).trimEnd()}…`;
}
const EVIDENCE_LABELS: Readonly<Record<string, string>> = {
message: "Message",
tool_call: "Tool call",
tool_result: "Tool result",
artifact_version: "Artifact version",
source: "Source",
passage: "Supporting passage",
memory: "Memory record",
approval: "Approval",
run: "Run",
url: "Website",
file: "File",
build: "Build",
flux: "Flux reconciliation",
pod: "Workload",
};
/** Evidence labels are deliberately detached from IDs, URIs, and payloads. */
export function safeEvidenceLabel(
kind: unknown,
): { kind: string; label: string } | null {
if (typeof kind !== "string" || !(kind in EVIDENCE_LABELS)) return null;
return { kind, label: EVIDENCE_LABELS[kind] };
}
export function isRfc3339Utc(value: unknown): value is string {
return (
typeof value === "string" &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) &&
!Number.isNaN(Date.parse(value))
);
}
export function isOpaqueId(value: unknown, prefix?: string): value is string {
if (typeof value !== "string") return false;
if (!/^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/.test(value)) return false;
return !prefix || value.startsWith(`${prefix}_`);
}

View File

@ -0,0 +1,85 @@
/* HUX-01 is opt-in. These scoped styles do not alter the existing WebUI. */
.hux-activity {
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, Canvas 94%, #12394a 6%);
color: CanvasText;
padding: 0.875rem;
}
.hux-activity-chat {
max-height: 24rem;
overflow: auto;
}
.hux-activity-row {
display: flex;
gap: 0.625rem;
border-radius: 0.5rem;
padding: 0.5rem;
}
.hux-activity-row:hover,
.hux-activity-row:focus-within {
background: color-mix(in srgb, currentColor 5%, transparent);
}
.hux-activity-marker {
display: grid;
width: 1.5rem;
height: 1.5rem;
flex: 0 0 auto;
place-items: center;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 9999px;
}
.hux-tone-active .hux-activity-marker {
color: #39c6df;
}
.hux-tone-success .hux-activity-marker {
color: #4ade80;
}
.hux-tone-warning .hux-activity-marker {
color: #f59e0b;
}
.hux-evidence-chip {
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 9999px;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
color: color-mix(in srgb, currentColor 65%, transparent);
}
.hux-activity-warning,
.hux-cancellation-receipt {
margin-top: 0.625rem;
border-radius: 0.5rem;
background: color-mix(in srgb, #f59e0b 10%, transparent);
padding: 0.5rem;
font-size: 0.6875rem;
}
.hux-activity-more {
margin-top: 0.625rem;
min-height: 2rem;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
color: color-mix(in srgb, currentColor 70%, transparent);
}
.hux-activity-more:hover,
.hux-activity-more:focus-visible {
background: color-mix(in srgb, currentColor 7%, transparent);
outline: 2px solid color-mix(in srgb, #39c6df 65%, transparent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.hux-activity * {
animation: none !important;
}
}

View File

@ -0,0 +1,94 @@
/** Safe frontend types for the opt-in HUX-01 activity timeline. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type ActivityPhase =
| "intent"
| "action"
| "evidence"
| "delegation"
| "decision"
| "completion"
| "failure";
export type ActivityTone = "neutral" | "active" | "success" | "warning";
export interface RawActivityEvent {
schema?: unknown;
id?: unknown;
seq?: unknown;
ts?: unknown;
conversation_id?: unknown;
run_id?: unknown;
parent_event_id?: unknown;
kind?: unknown;
summary?: unknown;
detail?: unknown;
evidence?: unknown;
provenance?: unknown;
sensitivity?: unknown;
redaction?: unknown;
}
export interface SafeEvidence {
kind: string;
label: string;
}
export interface ActivityItem {
id: string;
seq: number;
timestamp: string;
conversationId: string;
runId?: string;
kind: string;
phase: ActivityPhase;
tone: ActivityTone;
summary: string;
evidence: SafeEvidence[];
routine: boolean;
}
export interface SurfacePolicy {
maxItems: number;
initialItems: number;
evidenceLimit: number;
cancellationEffectLimit: number;
expandRoutineEvents: boolean;
}
export interface MergeResult {
items: ActivityItem[];
afterSeq: number;
invalid: number;
replays: number;
conflicts: number;
truncated: number;
}
export interface RawCancellationReceipt {
schema?: unknown;
id?: unknown;
run_id?: unknown;
requested_at?: unknown;
acknowledged_at?: unknown;
completed_at?: unknown;
outcome?: unknown;
side_effects?: unknown;
}
export interface CancellationEffect {
description: string;
reverted: boolean;
}
export interface CancellationReceipt {
id: string;
runId: string;
requestedAt: string;
acknowledgedAt?: string;
completedAt?: string;
outcome: "cancelled" | "already_complete" | "failed_to_cancel";
sideEffects: CancellationEffect[];
omittedEffects: number;
}

View File

@ -0,0 +1,181 @@
/** Accessible HUX-05 UI. Parent callbacks own every request and side effect. */
import { Ban, Hand, Octagon, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import {
AUTONOMY_LEVELS, BUDGET_MAX, CAPABILITIES, GRANT_DECISIONS,
autonomyControlsEnabled, buildApprovalDecision, buildPolicyUpdate,
buildStopIntent, normalizeAutonomyPage, normalizeStopReceipt,
} from "./model.ts";
import type {
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyUpdateIntent,
RawAutonomyEnvelope, StopIntent,
} from "./types.ts";
interface AutonomyControlsProps {
flags?: Iterable<string>;
identity: HuxIdentity;
conversationId: string;
page: RawAutonomyEnvelope;
activeRun?: ActiveRun | null;
stopReceipt?: Record<string, unknown> | null;
now?: number;
busy?: boolean;
onUpdatePolicy?: (intent: PolicyUpdateIntent) => void;
onDecideApproval?: (intent: ApprovalDecisionIntent) => void;
onStop?: (intent: StopIntent) => void;
}
const LABELS: Readonly<Record<Capability, string>> = {
read_files: "Read files", write_files: "Write files", shell: "Run commands",
network: "Use network", web_search: "Search the web", send_message: "Send messages",
memory_write: "Save memory", artifact_write: "Change artifacts",
spend_tokens: "Spend model tokens", delegate: "Delegate to agents", deploy: "Deploy changes",
};
const MODES: Readonly<Record<AutonomyLevel, {label: string; help: string}>> = {
ask_first: {label: "Ask First", help: "Pause before actions until you approve."},
safe: {label: "Safe Actions", help: "Read-only work may proceed; external effects still pause."},
autonomous: {label: "Autonomous", help: "Explicitly allowed capabilities may proceed within every budget."},
};
function startingBudget(page: ReturnType<typeof normalizeAutonomyPage>): BudgetDraft {
return {...page!.policy.budgets, spendCentsPerRun: 0, scopeLimit: "current_task"};
}
function CapabilityMatrix({grants, setGrant, busy}: {
grants: Readonly<Record<Capability, GrantDecision>>;
setGrant: (capability: Capability, decision: GrantDecision) => void;
busy: boolean;
}) {
return <fieldset className="hux-autonomy-matrix"><legend>Capability matrix</legend>
<p id="hux-autonomy-matrix-help">Missing or invalid grants are denied.</p>
<table><thead><tr><th scope="col">Capability</th><th scope="col">Decision</th></tr></thead>
<tbody>{CAPABILITIES.map((capability) => <tr key={capability}>
<th scope="row">{LABELS[capability]}</th><td><select
aria-label={`${LABELS[capability]} decision`} aria-describedby="hux-autonomy-matrix-help"
value={grants[capability]} disabled={busy}
onChange={(event) => setGrant(capability, event.target.value as GrantDecision)}>
{GRANT_DECISIONS.map((decision) => <option key={decision} value={decision}>
{decision === "allow" ? "Allow" : decision === "ask" ? "Ask" : "Deny"}
</option>)}</select></td>
</tr>)}</tbody></table>
</fieldset>;
}
function BudgetControls({budget, setBudget, busy}: {
budget: BudgetDraft;
setBudget: (next: BudgetDraft) => void;
busy: boolean;
}) {
const number = (field: keyof typeof BUDGET_MAX, label: string, step = 1) =>
<label>{label}<input type="number" min="0" max={BUDGET_MAX[field]} step={step}
value={budget[field]} disabled={busy} onChange={(event) => setBudget({...budget,
[field]: Number(event.target.value)})} /></label>;
return <fieldset className="hux-autonomy-budgets"><legend>Per-run budgets</legend>
<p id="hux-autonomy-budget-help">Zero blocks that resource. Limits are enforced together.</p>
<div aria-describedby="hux-autonomy-budget-help">
{number("wallClockSeconds", "Time (seconds)")}
{number("spendCentsPerRun", "Spend (cents)")}
{number("tokensPerRun", "Tokens", 1000)}
{number("toolCallsPerRun", "Tool calls")}
{number("delegationsPerRun", "Subagents")}
<label>Maximum scope<select value={budget.scopeLimit} disabled={busy}
onChange={(event) => setBudget({...budget, scopeLimit: event.target.value as BudgetDraft["scopeLimit"]})}>
<option value="current_task">Current task</option><option value="conversation">Conversation</option>
<option value="project">Project</option></select></label>
</div>
</fieldset>;
}
function ApprovalQueue({items, busy, decide}: {
items: readonly ApprovalView[];
busy: boolean;
decide: (approval: ApprovalView, choice: ApprovalChoice) => void;
}) {
return <section className="hux-autonomy-approvals" aria-labelledby="hux-approval-title">
<h3 id="hux-approval-title"><Hand aria-hidden /> Review external side effects</h3>
{items.length ? <ol>{items.map((approval) => <li key={approval.id} data-risk={approval.risk}>
<strong>{LABELS[approval.capability]}</strong><p>{approval.summary}</p>
<small>Risk: {approval.risk}. Expires <time dateTime={approval.expiresAt}>
{new Date(approval.expiresAt).toLocaleTimeString()}</time>.</small>
<div aria-label="Approval choices">
<button type="button" disabled={busy} onClick={() => decide(approval, "once")}>Allow once</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "session")}>This session</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "always")}>Always</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "deny")}><Ban aria-hidden /> Deny</button>
</div>
</li>)}</ol> : <p>No actions are waiting for approval.</p>}
</section>;
}
/** Render nothing unless foundation, timeline, and autonomy are negotiated. */
export function AutonomyControls(props: AutonomyControlsProps) {
const enabled = autonomyControlsEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeAutonomyPage(props.page, props.identity,
props.conversationId, props.now) : null,
[enabled, props.page, props.identity, props.conversationId, props.now]);
const [autonomy, setAutonomy] = useState<AutonomyLevel>("ask_first");
const [grants, setGrants] = useState<Readonly<Record<Capability, GrantDecision>> | null>(null);
const [budget, setBudget] = useState<BudgetDraft | null>(null);
if (!enabled) return null;
if (!page) return <section className="hux-autonomy" role="alert">
Autonomy controls are unavailable because identity or policy scope could not be verified.
</section>;
const selectedAutonomy = grants ? autonomy : page.policy.autonomy;
const selectedGrants = grants || page.policy.grants;
const selectedBudget = budget || startingBudget(page);
const receipt = props.stopReceipt && props.activeRun ?
normalizeStopReceipt(props.stopReceipt, props.activeRun, props.identity) : null;
function decide(approval: ApprovalView, choice: ApprovalChoice) {
try { props.onDecideApproval?.(buildApprovalDecision(approval, choice, props.identity,
approval.requestedAt, props.now)); } catch { /* a stale approval remains denied */ }
}
function stop() {
if (!props.activeRun) return;
try { props.onStop?.(buildStopIntent(props.activeRun, props.identity, props.activeRun.runId)); }
catch { /* an unowned run is never stopped */ }
}
return <section className="hux-autonomy" aria-labelledby="hux-autonomy-title" aria-busy={props.busy}>
<header><div><h2 id="hux-autonomy-title"><ShieldCheck aria-hidden /> Autonomy</h2>
<p>Control what Hermes may do, how far it may go, and what always pauses.</p></div>
<button className="hux-autonomy-stop" type="button" disabled={props.busy || !props.activeRun}
onClick={stop}><Octagon aria-hidden /> Stop current run</button>
</header>
<form onSubmit={(event) => { event.preventDefault();
try { props.onUpdatePolicy?.(buildPolicyUpdate(page.policy, props.identity,
selectedAutonomy, selectedGrants, selectedBudget)); } catch { /* invalid budgets fail closed */ }
}}>
<fieldset className="hux-autonomy-levels"><legend>Autonomy level</legend>
{AUTONOMY_LEVELS.map((level) => <label key={level}>
<input type="radio" name="hux-autonomy-level" value={level}
checked={selectedAutonomy === level} disabled={props.busy}
onChange={() => {setAutonomy(level); setGrants({...selectedGrants});}} />
<span><strong>{MODES[level].label}</strong><small>{MODES[level].help}</small></span>
</label>)}
</fieldset>
<CapabilityMatrix grants={selectedGrants} busy={!!props.busy}
setGrant={(capability, decision) => setGrants({...selectedGrants, [capability]: decision})} />
<BudgetControls budget={selectedBudget} busy={!!props.busy} setBudget={setBudget} />
<button className="hux-autonomy-save" type="submit" disabled={props.busy}>Save controls</button>
</form>
{page.rejectedApprovals > 0 && <p role="status" className="hux-autonomy-warning">
Some approval requests were withheld because they were stale or out of scope.</p>}
<ApprovalQueue items={page.approvals} busy={!!props.busy} decide={decide} />
{receipt && <section className="hux-autonomy-receipt" aria-labelledby="hux-stop-receipt-title">
<h3 id="hux-stop-receipt-title">Stop receipt</h3><p>Outcome: {receipt.outcome}.</p>
<p>{receipt.stopped} effects reverted; {receipt.remaining} remain.</p>
<ul>{receipt.effects.map((effect, index) => <li key={index}>
{effect.description} — {effect.reverted ? "reverted" : "remaining"}</li>)}</ul>
{receipt.omittedEffects > 0 && <p>{receipt.omittedEffects} additional effects omitted.</p>}
</section>}
<p className="sr-only" role="status" aria-live="polite">
{props.busy ? "Updating autonomy controls" : `${page.approvals.length} approvals waiting`}
</p>
</section>;
}

View File

@ -0,0 +1,48 @@
/** Same-origin endpoint descriptors for the future HUX-05 backend. */
import { ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, FOUNDATION_FLAG } from "./model.ts";
import { isOpaqueId, normalizeIdentity, sameIdentity } from "./security.ts";
import type { AutonomyEndpoint, AutonomyEndpointContract, HuxIdentity } from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
function endpoint(method: AutonomyEndpoint["method"], path: string,
responseSchema: string, requiredBody?: readonly string[]): AutonomyEndpoint {
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
}
/** Describe, but never call, the tenant-scoped HUX-05 interface. */
export function createAutonomyEndpointContract(client: FoundationClient): AutonomyEndpointContract | null {
if (client.apiVersion !== "hux.v1" ||
![FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG].every((flag) => client.enabled(flag))) return null;
const identity = normalizeIdentity(client.identity);
if (!identity || !sameIdentity(identity, client.identity)) throw new TypeError("Scoped HUX identity required");
const scoped = (root: string, id: string, prefix: string): string => {
if (!isOpaqueId(id, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
return client.endpoint(`${root}/${encodeURIComponent(id)}`);
};
return Object.freeze({
identity: Object.freeze({...identity}),
policy: endpoint("GET", client.endpoint("/autonomy/policy"), "hux.policy.v1"),
updatePolicy: endpoint("PUT", client.endpoint("/autonomy/policy"), "hux.policy.v1",
["expected_policy_id", "expected_updated_at", "autonomy", "grants", "budgets", "guardrails"]),
approvals: endpoint("GET", client.endpoint("/approvals?status=pending&limit=100"), "hux.approval_page.v1"),
decideApproval(approvalId: string) {
return endpoint("POST", `${scoped("/approvals", approvalId, "apr")}/decision`, "hux.approval.v1",
["expected_run_id", "expected_conversation_id", "expected_capability", "expected_requested_at", "choice"]);
},
stop(runId: string) {
if (!/^run_[A-Za-z0-9._-]{1,116}$/.test(runId)) throw new TypeError("Exact run id required");
return endpoint("POST", client.endpoint(`/runs/${encodeURIComponent(runId)}/stop`),
"hux.cancel_receipt.v1", ["conversation_id"]);
},
release: Object.freeze({schema: "hux.release.v1" as const,
requiredState: "live_verified" as const,
featureFlags: Object.freeze([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG])}),
});
}

View File

@ -0,0 +1,12 @@
export { AutonomyControls } from "./AutonomyControls.tsx";
export { createAutonomyEndpointContract } from "./endpoints.ts";
export {
ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, BUDGET_MAX, CAPABILITIES,
FOUNDATION_FLAG, actionAuthorization, autonomyControlsEnabled,
buildApprovalDecision, buildPolicyUpdate, buildStopIntent,
normalizeAutonomyPage, normalizeStopReceipt,
} from "./model.ts";
export type {
ActiveRun, ApprovalDecisionIntent, ApprovalView, AutonomyPage, BudgetDraft,
Capability, HuxIdentity, PolicyUpdateIntent, StopIntent, StopReceiptView,
} from "./types.ts";

View File

@ -0,0 +1,191 @@
/** Schema normalization and fail-closed decisions for HUX-05. */
import { normalizeCancellationReceipt } from "../activity/model.ts";
import {
boundedInteger, exactKeys, identityKey, isOpaqueId, isUtc, normalizeIdentity,
safeText, sameIdentity,
} from "./security.ts";
import type {
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
AutonomyPage, BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyScope,
PolicyUpdateIntent, PolicyView, RawAutonomyEnvelope, StopIntent, StopReceiptView,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline";
export const AUTONOMY_FLAG = "hux.autonomy";
export const CAPABILITIES: readonly Capability[] = [
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
"external_side_effect",
];
export const AUTONOMY_LEVELS: readonly AutonomyLevel[] = ["ask_first", "safe", "autonomous"];
export const GRANT_DECISIONS: readonly GrantDecision[] = ["allow", "ask", "deny"];
export const BUDGET_MAX = Object.freeze({
tokensPerRun: 2_000_000, toolCallsPerRun: 500, wallClockSeconds: 86_400,
delegationsPerRun: 32, spendCentsPerRun: 100_000,
});
const EXTERNAL = new Set<Capability>([
"write_files", "shell", "network", "send_message", "memory_write",
"artifact_write", "spend_tokens", "delegate", "deploy",
"external_side_effect",
]);
export function autonomyControlsEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const values = new Set(flags);
return [FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]
.every((flag) => values.has(flag));
}
function normalizeScope(raw: unknown): PolicyScope | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
if (!exactKeys(value, value.level === "global" ? ["level"] : ["level", "scope_id"])) return null;
if (!["global", "project", "conversation"].includes(String(value.level))) return null;
if (value.level !== "global" && !isOpaqueId(value.scope_id)) return null;
return {level: value.level as PolicyScope["level"],
...(value.level === "global" ? {} : {scopeId: value.scope_id as string})};
}
function normalizePolicy(raw: unknown, expected: HuxIdentity): PolicyView | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const required = ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"];
if (!exactKeys(value, required) || value.schema !== "hux.policy.v1" ||
!isOpaqueId(value.id, "pol") || value.owner !== expected.userRef ||
!AUTONOMY_LEVELS.includes(value.autonomy as AutonomyLevel) || !isUtc(value.updated_at)) return null;
const scope = normalizeScope(value.scope);
if (!scope || !Array.isArray(value.grants) || value.grants.length > 64 ||
!value.budgets || typeof value.budgets !== "object" || Array.isArray(value.budgets)) return null;
const grants = Object.fromEntries(CAPABILITIES.map((capability) => [capability, "deny"])) as Record<Capability, GrantDecision>;
const seen = new Set<Capability>();
for (const rawGrant of value.grants) {
if (!rawGrant || typeof rawGrant !== "object" || Array.isArray(rawGrant)) return null;
const grant = rawGrant as Record<string, unknown>;
if (!CAPABILITIES.includes(grant.capability as Capability) ||
!GRANT_DECISIONS.includes(grant.decision as GrantDecision) || seen.has(grant.capability as Capability)) return null;
seen.add(grant.capability as Capability);
grants[grant.capability as Capability] = grant.decision as GrantDecision;
}
const budget = value.budgets as Record<string, unknown>;
const budgetKeys = ["tokens_per_run", "tool_calls_per_run", "wall_clock_seconds", "delegations_per_run"];
if (Object.keys(budget).some((key) => !budgetKeys.includes(key))) return null;
const normalized = {
tokensPerRun: boundedInteger(budget.tokens_per_run ?? 0, BUDGET_MAX.tokensPerRun),
toolCallsPerRun: boundedInteger(budget.tool_calls_per_run ?? 0, BUDGET_MAX.toolCallsPerRun),
wallClockSeconds: boundedInteger(budget.wall_clock_seconds ?? 0, BUDGET_MAX.wallClockSeconds),
delegationsPerRun: boundedInteger(budget.delegations_per_run ?? 0, BUDGET_MAX.delegationsPerRun),
};
if (Object.values(normalized).some((item) => item === null)) return null;
return {id: value.id, owner: value.owner as string, scope,
autonomy: value.autonomy as AutonomyLevel, grants: Object.freeze({...grants}),
budgets: normalized as PolicyView["budgets"], updatedAt: value.updated_at};
}
function normalizeApproval(raw: unknown, expected: HuxIdentity, conversationId: string,
now: number): ApprovalView | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const keys = ["schema", "id", "run_id", "conversation_id", "capability", "request",
"status", "requested_at", "expires_at"];
if (!exactKeys(value, keys) || value.schema !== "hux.approval.v1" ||
!isOpaqueId(value.id, "apr") || typeof value.run_id !== "string" || !value.run_id ||
value.run_id.length > 120 || value.conversation_id !== conversationId ||
!CAPABILITIES.includes(value.capability as Capability) || value.status !== "pending" ||
!isUtc(value.requested_at) || !isUtc(value.expires_at) ||
Date.parse(value.requested_at) > Date.parse(value.expires_at) || Date.parse(value.expires_at) <= now) return null;
if (!value.request || typeof value.request !== "object" || Array.isArray(value.request)) return null;
const request = value.request as Record<string, unknown>;
if (Object.keys(request).some((key) => !["summary", "detail", "risk", "evidence"].includes(key)) ||
!["low", "medium", "high"].includes(String(request.risk))) return null;
return {id: value.id, identityKey: identityKey(expected), runId: value.run_id,
conversationId, capability: value.capability as Capability,
summary: safeText(request.summary, "Hermes requests permission"),
risk: request.risk as ApprovalView["risk"], requestedAt: value.requested_at,
expiresAt: value.expires_at, status: "pending"};
}
export function normalizeAutonomyPage(raw: RawAutonomyEnvelope, expected: HuxIdentity,
conversationId: string, now = Date.now()): AutonomyPage | null {
if (!raw || typeof raw !== "object" || raw.schema !== "hux.autonomy_page.v1" ||
raw.api_version !== "hux.v1" || raw.conversation_id !== conversationId || !isOpaqueId(conversationId, "conv")) return null;
const actual = normalizeIdentity(raw.identity);
if (!actual || !sameIdentity(actual, expected)) return null;
const policy = normalizePolicy(raw.policy, expected);
if (!policy || !Array.isArray(raw.approvals) || raw.approvals.length > 100) return null;
const approvals = raw.approvals.map((item) => normalizeApproval(item, expected, conversationId, now));
return {policy, approvals: approvals.filter((item): item is ApprovalView => item !== null),
rejectedApprovals: approvals.filter((item) => item === null).length};
}
function boundedDraft(draft: BudgetDraft): boolean {
return boundedInteger(draft.tokensPerRun, BUDGET_MAX.tokensPerRun) !== null &&
boundedInteger(draft.toolCallsPerRun, BUDGET_MAX.toolCallsPerRun) !== null &&
boundedInteger(draft.wallClockSeconds, BUDGET_MAX.wallClockSeconds) !== null &&
boundedInteger(draft.delegationsPerRun, BUDGET_MAX.delegationsPerRun) !== null &&
boundedInteger(draft.spendCentsPerRun, BUDGET_MAX.spendCentsPerRun) !== null &&
["current_task", "conversation", "project"].includes(draft.scopeLimit);
}
export function buildPolicyUpdate(policy: PolicyView, identity: HuxIdentity,
autonomy: AutonomyLevel, grants: Readonly<Record<Capability, GrantDecision>>,
draft: BudgetDraft): PolicyUpdateIntent {
if (policy.owner !== identity.userRef || !AUTONOMY_LEVELS.includes(autonomy) || !boundedDraft(draft) ||
CAPABILITIES.some((capability) => !GRANT_DECISIONS.includes(grants[capability]))) {
throw new TypeError("Autonomy policy update is outside its bounds");
}
return {expectedPolicyId: policy.id, expectedUpdatedAt: policy.updatedAt,
identity: {...identity}, scope: {...policy.scope}, autonomy, grants: Object.freeze({...grants}),
budgets: {tokensPerRun: draft.tokensPerRun, toolCallsPerRun: draft.toolCallsPerRun,
wallClockSeconds: draft.wallClockSeconds, delegationsPerRun: draft.delegationsPerRun},
guardrails: {spendCentsPerRun: draft.spendCentsPerRun, scopeLimit: draft.scopeLimit}};
}
export function buildApprovalDecision(approval: ApprovalView, choice: ApprovalChoice,
identity: HuxIdentity, expectedRequestedAt: string, now = Date.now()): ApprovalDecisionIntent {
if (approval.identityKey !== identityKey(identity) || approval.requestedAt !== expectedRequestedAt ||
approval.status !== "pending" || Date.parse(approval.expiresAt) <= now ||
!["once", "session", "always", "deny"].includes(choice)) {
throw new TypeError("Stale or mismatched approval denied");
}
return {approvalId: approval.id, expectedRunId: approval.runId,
expectedConversationId: approval.conversationId, expectedCapability: approval.capability,
expectedRequestedAt, choice, identity: {...identity}};
}
export function actionAuthorization(policy: PolicyView | null, capability: Capability,
_approval: ApprovalView | null, identity: HuxIdentity, _now = Date.now()): "allow" | "approval_required" | "deny" {
if (!policy || policy.owner !== identity.userRef || !CAPABILITIES.includes(capability)) return "deny";
const decision = policy.grants[capability];
if (decision === "deny") return "deny";
const mustAsk = decision === "ask" || policy.autonomy === "ask_first" ||
(policy.autonomy === "safe" && EXTERNAL.has(capability));
if (!mustAsk) return "allow";
// A pending UI record is never execution authority. The server may perform
// the action only after its decision endpoint atomically validates and
// consumes the exact approval proof built by buildApprovalDecision().
return "approval_required";
}
export function buildStopIntent(run: ActiveRun, identity: HuxIdentity,
expectedRunId: string): StopIntent {
if (run.runId !== expectedRunId || !run.runId || run.runId.length > 120 ||
run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef ||
!isOpaqueId(run.conversationId, "conv")) throw new TypeError("Run ownership could not be verified");
return {runId: run.runId, conversationId: run.conversationId, identity: {...identity}};
}
export function normalizeStopReceipt(raw: Record<string, unknown>, run: ActiveRun,
identity: HuxIdentity): StopReceiptView | null {
const requestedBy = raw?.requested_by as Record<string, unknown> | null;
if (!requestedBy || requestedBy.type !== "user" || requestedBy.id !== identity.userRef ||
run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef || raw.run_id !== run.runId) return null;
const receipt = normalizeCancellationReceipt(raw, identity.surface);
if (!receipt || receipt.runId !== run.runId) return null;
return {id: receipt.id, runId: receipt.runId, outcome: receipt.outcome,
stopped: receipt.sideEffects.filter((item) => item.reverted).length,
remaining: receipt.sideEffects.filter((item) => !item.reverted).length,
effects: receipt.sideEffects, omittedEffects: receipt.omittedEffects};
}

View File

@ -0,0 +1,59 @@
/** Small, fail-closed validation helpers for HUX-05. */
import type { HuxIdentity } from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
export function isOpaqueId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
}
export function safeText(value: unknown, fallback: string, limit = 280): string {
if (typeof value !== "string") return fallback;
const clean = value.replace(SECRET, "$1=[redacted]").replace(BEARER, "Bearer [redacted]")
.replace(CONTROLS, " ").replace(/\s+/g, " ").trim();
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const tenantRef = value.tenant_ref ?? value.tenantRef;
const userRef = value.user_ref ?? value.userRef;
const surfaces = new Set(["chat", "worker", "telegram", "voice", "api"]);
if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) ||
typeof userRef !== "string" || !USER.test(userRef) ||
typeof value.surface !== "string" || !surfaces.has(value.surface)) return null;
return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]};
}
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function identityKey(value: HuxIdentity): string {
return `${value.tenantRef}:${value.userRef}:${value.surface}`;
}
export function exactKeys(value: object, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index]);
}
export function boundedInteger(value: unknown, maximum: number): number | null {
return Number.isSafeInteger(value) && (value as number) >= 0 &&
(value as number) <= maximum ? value as number : null;
}

View File

@ -0,0 +1,165 @@
.hux-autonomy {
color: var(--hermes-text, #e8edf7);
display: grid;
gap: 1rem;
}
.hux-autonomy > header {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.hux-autonomy h2,
.hux-autonomy h3 {
align-items: center;
display: flex;
gap: 0.45rem;
margin: 0;
}
.hux-autonomy form {
display: grid;
gap: 1rem;
}
.hux-autonomy fieldset,
.hux-autonomy-approvals,
.hux-autonomy-receipt {
border: 1px solid color-mix(in srgb, var(--hermes-accent, #32d6d2) 30%, transparent);
border-radius: 0.8rem;
padding: 1rem;
}
.hux-autonomy-levels {
display: grid;
gap: 0.65rem;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.hux-autonomy-levels label {
align-items: start;
background: color-mix(in srgb, var(--hermes-panel, #121a27) 90%, transparent);
border-radius: 0.6rem;
display: flex;
gap: 0.55rem;
padding: 0.75rem;
}
.hux-autonomy-levels small,
.hux-autonomy-approvals small {
display: block;
opacity: 0.76;
}
.hux-autonomy-matrix table {
border-collapse: collapse;
width: 100%;
}
.hux-autonomy-matrix th,
.hux-autonomy-matrix td {
border-block-start: 1px solid rgb(255 255 255 / 8%);
padding: 0.5rem;
text-align: start;
}
.hux-autonomy-budgets > div {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.hux-autonomy-budgets label {
display: grid;
gap: 0.3rem;
}
.hux-autonomy input,
.hux-autonomy select,
.hux-autonomy button {
font: inherit;
}
.hux-autonomy input,
.hux-autonomy select {
background: var(--hermes-input, #0d1420);
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 0.4rem;
color: inherit;
padding: 0.45rem;
}
.hux-autonomy button {
border-radius: 0.5rem;
cursor: pointer;
padding: 0.55rem 0.8rem;
}
.hux-autonomy-stop {
align-items: center;
background: #9e2638;
border: 2px solid #ff6c7d;
color: white;
display: inline-flex;
font-weight: 700;
gap: 0.4rem;
}
.hux-autonomy-save {
justify-self: end;
}
.hux-autonomy-approvals ol {
display: grid;
gap: 0.75rem;
list-style: none;
padding: 0;
}
.hux-autonomy-approvals li {
background: rgb(255 255 255 / 4%);
border-inline-start: 3px solid var(--hermes-accent, #32d6d2);
padding: 0.75rem;
}
.hux-autonomy-approvals li[data-risk="high"] {
border-inline-start-color: #ff6c7d;
}
.hux-autonomy-approvals li > div {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-block-start: 0.65rem;
}
.hux-autonomy-warning {
color: #ffd479;
}
.hux-autonomy :focus-visible {
outline: 3px solid var(--hermes-focus, #71f7ef);
outline-offset: 2px;
}
.hux-autonomy button:disabled,
.hux-autonomy input:disabled,
.hux-autonomy select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (max-width: 44rem) {
.hux-autonomy > header {
align-items: stretch;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-autonomy * {
scroll-behavior: auto;
}
}

View File

@ -0,0 +1,140 @@
/** Frontend-only contracts for default-off HUX-05 autonomy controls. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type AutonomyLevel = "ask_first" | "safe" | "autonomous";
export type GrantDecision = "allow" | "ask" | "deny";
export type ApprovalChoice = "once" | "session" | "always" | "deny";
export type Risk = "low" | "medium" | "high";
export type ScopeLevel = "global" | "project" | "conversation";
export type ScopeLimit = "current_task" | "conversation" | "project";
export type Capability =
| "read_files" | "write_files" | "shell" | "network" | "web_search"
| "send_message" | "memory_write" | "artifact_write" | "spend_tokens"
| "delegate" | "deploy" | "external_side_effect";
export interface HuxIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface PolicyScope {
level: ScopeLevel;
scopeId?: string;
}
export interface PolicyBudgets {
tokensPerRun: number;
toolCallsPerRun: number;
wallClockSeconds: number;
delegationsPerRun: number;
}
export interface PolicyView {
id: string;
owner: string;
scope: PolicyScope;
autonomy: AutonomyLevel;
grants: Readonly<Record<Capability, GrantDecision>>;
budgets: PolicyBudgets;
updatedAt: string;
}
export interface BudgetDraft extends PolicyBudgets {
/** Request guardrails; the backend must enforce these atomically. */
spendCentsPerRun: number;
scopeLimit: ScopeLimit;
}
export interface ApprovalView {
id: string;
identityKey: string;
runId: string;
conversationId: string;
capability: Capability;
summary: string;
risk: Risk;
requestedAt: string;
expiresAt: string;
status: "pending";
}
export interface RawAutonomyEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
conversation_id?: unknown;
policy?: unknown;
approvals?: unknown;
}
export interface AutonomyPage {
policy: PolicyView;
approvals: ApprovalView[];
rejectedApprovals: number;
}
export interface PolicyUpdateIntent {
expectedPolicyId: string;
expectedUpdatedAt: string;
identity: HuxIdentity;
scope: PolicyScope;
autonomy: AutonomyLevel;
grants: Readonly<Record<Capability, GrantDecision>>;
budgets: PolicyBudgets;
guardrails: {spendCentsPerRun: number; scopeLimit: ScopeLimit};
}
export interface ApprovalDecisionIntent {
approvalId: string;
expectedRunId: string;
expectedConversationId: string;
expectedCapability: Capability;
expectedRequestedAt: string;
choice: ApprovalChoice;
identity: HuxIdentity;
}
export interface ActiveRun {
runId: string;
conversationId: string;
tenantRef: string;
owner: string;
}
export interface StopIntent {
runId: string;
conversationId: string;
identity: HuxIdentity;
}
export interface StopReceiptView {
id: string;
runId: string;
outcome: "cancelled" | "already_complete" | "failed_to_cancel";
stopped: number;
remaining: number;
effects: {description: string; reverted: boolean}[];
omittedEffects: number;
}
export interface AutonomyEndpoint {
method: "GET" | "PUT" | "POST";
path: string;
responseSchema: string;
requiredBody?: readonly string[];
}
export interface AutonomyEndpointContract {
identity: HuxIdentity;
policy: AutonomyEndpoint;
updatePolicy: AutonomyEndpoint;
approvals: AutonomyEndpoint;
decideApproval(approvalId: string): AutonomyEndpoint;
stop(runId: string): AutonomyEndpoint;
release: {
schema: "hux.release.v1";
requiredState: "live_verified";
featureFlags: readonly string[];
};
}

View File

@ -0,0 +1,346 @@
/** Accessible HUX-02 ledger UI. No network request is made by this module. */
import { EyeOff, History, Pencil, ShieldCheck, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import {
MEMORY_TOPICS,
memoryControlEnabled,
normalizeMemoryPage,
} from "./model.ts";
import { isControlReference } from "./security.ts";
import type {
DoNotRememberTarget,
HuxIdentity,
MemoryItem,
MemoryTopic,
RawMemoryPage,
} from "./types.ts";
interface MemoryControlCenterProps {
flags?: Iterable<string>;
identity: HuxIdentity;
page: RawMemoryPage;
conversationId?: string;
messageId?: string;
busyId?: string;
onApprove?: (memoryId: string) => void;
onReject?: (memoryId: string) => void;
onEditAsNew?: (memoryId: string, content: string) => void;
onForget?: (memoryId: string) => void;
onOpenSourceMessage?: (memoryId: string) => void;
onDoNotRemember?: (target: DoNotRememberTarget) => void;
onRequireSuggestOnly?: () => void;
}
function LedgerActions({
item,
busy,
onApprove,
onReject,
onEdit,
onForget,
onOpenSource,
}: {
item: MemoryItem;
busy: boolean;
onApprove?: () => void;
onReject?: () => void;
onEdit?: () => void;
onForget?: () => void;
onOpenSource?: () => void;
}) {
if (item.status === "forgotten" || item.status === "expired") return null;
if (item.status === "proposed") {
return (
<div className="hux-memory-actions" aria-label="Approval choices">
{item.sourceIsMessage && onOpenSource && (
<button type="button" disabled={busy} onClick={onOpenSource}>
View source message
</button>
)}
<button type="button" disabled={busy} onClick={onApprove}>
Remember
</button>
<button type="button" disabled={busy} onClick={onReject}>
Reject
</button>
</div>
);
}
return (
<div className="hux-memory-actions">
{item.sourceIsMessage && onOpenSource && (
<button type="button" disabled={busy} onClick={onOpenSource}>
View source message
</button>
)}
{item.content !== null && (
<button type="button" disabled={busy} onClick={onEdit}>
<Pencil aria-hidden /> Edit as new entry
</button>
)}
<button type="button" disabled={busy} onClick={onForget}>
<Trash2 aria-hidden /> Delete &amp; forget
</button>
</div>
);
}
function MemoryCard({
item,
busy,
editing,
draft,
setDraft,
beginEdit,
cancelEdit,
saveEdit,
approve,
reject,
forget,
openSource,
}: {
item: MemoryItem;
busy: boolean;
editing: boolean;
draft: string;
setDraft: (value: string) => void;
beginEdit: () => void;
cancelEdit: () => void;
saveEdit: () => void;
approve: () => void;
reject: () => void;
forget: () => void;
openSource?: () => void;
}) {
return (
<li className="hux-memory-card" data-status={item.status}>
<header>
<span className="hux-memory-kind">{item.kind}</span>
<span className="hux-memory-status">{item.status}</span>
</header>
{item.content === null ? (
<p className="hux-memory-hidden">
<EyeOff aria-hidden /> {item.contentNotice}
</p>
) : (
<p className="hux-memory-content">{item.content}</p>
)}
<dl className="hux-memory-meta">
<div>
<dt>Why</dt><dd>{item.reason}</dd>
</div>
<div>
<dt>Source</dt><dd>{item.sourceLabel}</dd>
</div>
<div>
<dt>Recorded by</dt><dd>{item.provenanceLabel}</dd>
</div>
<div>
<dt>Scope</dt><dd>{item.scopeLabel}</dd>
</div>
<div>
<dt>Sensitivity</dt><dd>{item.sensitivity}</dd>
</div>
<div>
<dt>Approval</dt>
<dd>{item.approvalMode === "ask" ? "Ask first" : "Automatic (historical)"}</dd>
</div>
<div>
<dt>Retention</dt><dd>{item.ttlLabel}</dd>
</div>
<div>
<dt>Saved</dt>
<dd>
<time dateTime={item.savedAt}>
{new Date(item.savedAt).toLocaleString()}
</time>
</dd>
</div>
</dl>
{editing && (
<form
className="hux-memory-edit"
onSubmit={(event) => {
event.preventDefault();
saveEdit();
}}
>
<label htmlFor={`hux-memory-edit-${item.id}`}>Proposed replacement</label>
<textarea
id={`hux-memory-edit-${item.id}`}
value={draft}
maxLength={2000}
required
autoFocus
onChange={(event) => setDraft(event.target.value)}
/>
<p>
The original stays in the audit ledger. This replacement needs
approval.
</p>
<div className="hux-memory-actions">
<button type="submit" disabled={busy || !draft.trim()}>
Propose change
</button>
<button type="button" onClick={cancelEdit}>Cancel</button>
</div>
</form>
)}
{!editing && (
<LedgerActions
item={item}
busy={busy}
onApprove={approve}
onReject={reject}
onEdit={beginEdit}
onForget={forget}
onOpenSource={openSource}
/>
)}
</li>
);
}
/** Render nothing unless foundation, privacy, and memory-control are enabled. */
export function MemoryControlCenter(props: MemoryControlCenterProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [topic, setTopic] = useState<MemoryTopic>("general");
const enabled = memoryControlEnabled(props.flags);
const page = useMemo(
() => {
if (!enabled) return null;
try {
return normalizeMemoryPage(props.page, props.identity);
} catch {
return null;
}
},
[enabled, props.identity, props.page],
);
if (!enabled) return null;
if (!page) {
return (
<section className="hux-memory" role="alert">
Memory is unavailable because its workspace scope could not be verified.
</section>
);
}
function beginEdit(item: MemoryItem) {
setEditingId(item.id);
setDraft(item.content || "");
}
function saveEdit(item: MemoryItem) {
const value = draft.trim();
if (value) props.onEditAsNew?.(item.id, value);
setEditingId(null);
setDraft("");
}
return (
<section className="hux-memory" aria-labelledby="hux-memory-title">
<header className="hux-memory-heading">
<div>
<h2 id="hux-memory-title">
<History aria-hidden /> Memory
</h2>
<p>
An append-only record of what Hermes may use in future
conversations.
</p>
</div>
<div className="hux-memory-approval" role="status">
<ShieldCheck aria-hidden />
<span>Suggest only — nothing new is saved without your approval.</span>
<button type="button" onClick={props.onRequireSuggestOnly}>Enforce</button>
</div>
</header>
<fieldset className="hux-memory-blocks">
<legend>Do not remember</legend>
{isControlReference(props.messageId) && (
<button
type="button"
onClick={() =>
props.onDoNotRemember?.({
kind: "message",
messageId: props.messageId as string,
})
}
>
This message
</button>
)}
{isControlReference(props.conversationId, "conv") && (
<button
type="button"
onClick={() =>
props.onDoNotRemember?.({
kind: "conversation",
conversationId: props.conversationId as string,
})
}
>
This conversation
</button>
)}
<label htmlFor="hux-memory-topic">Topic</label>
<select
id="hux-memory-topic"
value={topic}
onChange={(event) => setTopic(event.target.value as MemoryTopic)}
>
{MEMORY_TOPICS.map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
<button
type="button"
onClick={() => props.onDoNotRemember?.({ kind: "topic", topic })}
>
Block topic
</button>
</fieldset>
{(page.invalid > 0 || page.truncated > 0) && (
<p className="hux-memory-warning" role="status">
Some entries were withheld because they were unsafe, out of scope, or
beyond this page.
</p>
)}
<ol className="hux-memory-ledger" aria-label="Memory ledger">
{page.entries.map((item) => (
<MemoryCard
key={item.id}
item={item}
busy={props.busyId === item.id}
editing={editingId === item.id}
draft={draft}
setDraft={setDraft}
beginEdit={() => beginEdit(item)}
cancelEdit={() => {
setEditingId(null);
setDraft("");
}}
saveEdit={() => saveEdit(item)}
approve={() => props.onApprove?.(item.id)}
reject={() => props.onReject?.(item.id)}
forget={() => props.onForget?.(item.id)}
openSource={
props.onOpenSourceMessage
? () => props.onOpenSourceMessage?.(item.id)
: undefined
}
/>
))}
</ol>
{page.entries.length === 0 && (
<p className="hux-memory-empty">Hermes has no memory entries to show.</p>
)}
</section>
);
}

View File

@ -0,0 +1,79 @@
/**
* Same-origin endpoint descriptions for a future HUX-02 backend adapter.
* The server must derive identity from the authenticated session, compare it
* with each request's expectedIdentity, and echo it in list responses. A
* revision appends a proposed ledger entry; forget purges content while
* retaining audit metadata. These descriptors never perform a request.
*/
import { memoryControlEnabled } from "./model.ts";
import { isOpaqueMemoryId, normalizeIdentity } from "./security.ts";
import type {
HuxIdentity,
MemoryAction,
MemoryEndpoint,
MemoryEndpointContract,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
endpoint(path: string): string;
}
function endpoint(
method: MemoryEndpoint["method"],
path: string,
responseSchema: string,
): MemoryEndpoint {
return Object.freeze({ method, path, responseSchema });
}
/**
* Describe, but do not call, the backend interface. Returning null while any
* dependency flag is absent keeps HUX-02 inert and default-off.
*/
export function createMemoryEndpointContract(
client: FoundationClient,
flags?: Iterable<string>,
): MemoryEndpointContract | null {
if (!memoryControlEnabled(flags)) return null;
if (client.apiVersion !== "hux.v1") throw new TypeError("HUX v1 client required");
const identity = Object.freeze({ ...normalizeIdentity(client.identity) });
function itemPath(memoryId: string): string {
if (!isOpaqueMemoryId(memoryId)) throw new TypeError("Opaque memory id required");
return client.endpoint(`/memories/${memoryId}`);
}
return Object.freeze({
identity,
list: endpoint("GET", client.endpoint("/memories"), "hux.memory_page.v1"),
suggestOnly: endpoint(
"PUT",
client.endpoint("/memory-settings/suggest-only"),
"hux.memory_settings.v1",
),
doNotRemember: endpoint(
"POST",
client.endpoint("/memory-blocks"),
"hux.memory_block.v1",
),
action(memoryId: string, action: MemoryAction) {
if (!["approve", "reject", "forget"].includes(action))
throw new TypeError("Unsupported memory action");
return endpoint(
"POST",
`${itemPath(memoryId)}/${action}`,
"hux.memory.v1",
);
},
revise(memoryId: string) {
return endpoint(
"POST",
`${itemPath(memoryId)}/revisions`,
"hux.memory.v1",
);
},
});
}

View File

@ -0,0 +1,24 @@
export { MemoryControlCenter } from "./MemoryControlCenter.tsx";
export { createMemoryEndpointContract } from "./endpoints.ts";
export {
FOUNDATION_FLAG,
MEMORY_CONTROL_FLAG,
MEMORY_TOPICS,
PRIVACY_FLAG,
memoryControlEnabled,
normalizeMemoryPage,
} from "./model.ts";
export { MemoryContractError } from "./security.ts";
export type {
DoNotRememberTarget,
HuxIdentity,
MemoryBlockRequest,
MemoryEndpointContract,
MemoryItem,
MemoryMutationRequest,
MemoryPage,
MemoryRevisionRequest,
RawMemoryEntry,
RawMemoryPage,
SuggestOnlyRequest,
} from "./types.ts";

View File

@ -0,0 +1,239 @@
/** 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,
};
}

View File

@ -0,0 +1,96 @@
/** HUX-02 display and scope validation. Raw payloads never cross this boundary. */
import type { HuxIdentity, HuxSurface } from "./types.ts";
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
const AUTH =
/\b(password|passwd|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi;
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
const PEM = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi;
const BLOB =
/\b(?=[A-Za-z0-9_+/#.-]{40,}\b)(?=[A-Za-z0-9_+/#.-]*[A-Za-z])(?=[A-Za-z0-9_+/#.-]*\d)[A-Za-z0-9_+/#.-]+\b/g;
const URI_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
const SURFACES = new Set<HuxSurface>([
"chat",
"worker",
"telegram",
"voice",
"api",
]);
export class MemoryContractError extends Error {
constructor(message: string) {
super(message);
this.name = "MemoryContractError";
}
}
export function safeMemoryText(
value: unknown,
fallback: string,
maxLength = 280,
): string {
if (typeof value !== "string") return fallback;
const limit = Math.max(1, maxLength);
const safe = value
.replace(PEM, "[redacted credential]")
.replace(AUTH, "$1=[redacted]")
.replace(BEARER, "Bearer [redacted]")
.replace(JWT, "[redacted credential]")
.replace(URI_USERINFO, "$1[redacted]@")
.replace(BLOB, "[redacted credential]")
.replace(CONTROL, " ")
.replace(/\s+/g, " ")
.trim();
if (!safe) return fallback;
if (safe.length <= limit) return safe;
return limit === 1 ? "…" : `${safe.slice(0, limit - 1).trimEnd()}…`;
}
export function isOpaqueMemoryId(value: unknown): value is string {
return typeof value === "string" && /^mem_[A-Za-z0-9._-]{4,80}$/.test(value);
}
export function isControlReference(
value: unknown,
prefix?: "conv",
): value is string {
if (typeof value !== "string") return false;
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(value)) return false;
return !prefix || value.startsWith(`${prefix}_`);
}
export function isUtcTimestamp(value: unknown): value is string {
return (
typeof value === "string" &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) &&
!Number.isNaN(Date.parse(value))
);
}
export function normalizeIdentity(value: unknown): HuxIdentity {
const raw = value as Record<string, unknown> | null;
const tenantRef = raw?.tenant_ref ?? raw?.tenantRef;
const userRef = raw?.user_ref ?? raw?.userRef;
if (
!raw ||
Array.isArray(raw) ||
typeof tenantRef !== "string" ||
!/^tnt_[0-9a-f]{16,64}$/.test(tenantRef) ||
typeof userRef !== "string" ||
!/^usr_[0-9a-f]{16,64}$/.test(userRef) ||
!SURFACES.has(raw.surface as HuxSurface)
) {
throw new MemoryContractError("Memory identity could not be verified");
}
return { tenantRef, userRef, surface: raw.surface as HuxSurface };
}
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
return (
left.tenantRef === right.tenantRef &&
left.userRef === right.userRef &&
left.surface === right.surface
);
}

View File

@ -0,0 +1,197 @@
.hux-memory {
color: var(--hux-text, #dce8f4);
display: grid;
gap: 1rem;
}
.hux-memory-heading {
align-items: start;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: space-between;
}
.hux-memory-heading h2,
.hux-memory-heading h2 svg,
.hux-memory-hidden,
.hux-memory-actions,
.hux-memory-approval {
align-items: center;
display: flex;
}
.hux-memory-heading h2 {
gap: 0.4rem;
margin: 0;
}
.hux-memory-heading p,
.hux-memory-edit p {
color: color-mix(in srgb, currentColor 62%, transparent);
font-size: 0.72rem;
margin: 0.25rem 0 0;
}
.hux-memory-approval {
background: color-mix(in srgb, #25d4c3 10%, transparent);
border: 1px solid color-mix(in srgb, #25d4c3 35%, transparent);
border-radius: 0.75rem;
font-size: 0.72rem;
gap: 0.4rem;
max-width: 24rem;
padding: 0.55rem 0.65rem;
}
.hux-memory-blocks {
align-items: center;
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
border-radius: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0.65rem;
}
.hux-memory-blocks legend {
font-size: 0.72rem;
font-weight: 700;
padding: 0 0.3rem;
}
.hux-memory button,
.hux-memory select,
.hux-memory textarea {
background: color-mix(in srgb, currentColor 7%, transparent);
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.5rem;
color: inherit;
font: inherit;
}
.hux-memory button {
cursor: pointer;
padding: 0.38rem 0.55rem;
}
.hux-memory button:focus-visible,
.hux-memory select:focus-visible,
.hux-memory textarea:focus-visible {
outline: 2px solid var(--hux-accent, #25d4c3);
outline-offset: 2px;
}
.hux-memory button:disabled {
cursor: wait;
opacity: 0.55;
}
.hux-memory-ledger {
display: grid;
gap: 0.65rem;
list-style: none;
margin: 0;
padding: 0;
}
.hux-memory-card {
background: color-mix(in srgb, currentColor 3.5%, transparent);
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
border-radius: 0.8rem;
padding: 0.8rem;
}
.hux-memory-card > header {
display: flex;
gap: 0.5rem;
justify-content: space-between;
}
.hux-memory-kind,
.hux-memory-status {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hux-memory-status {
color: var(--hux-accent, #25d4c3);
}
.hux-memory-content {
font-size: 0.86rem;
line-height: 1.55;
margin: 0.65rem 0;
white-space: pre-wrap;
}
.hux-memory-hidden {
color: color-mix(in srgb, currentColor 62%, transparent);
font-size: 0.78rem;
gap: 0.4rem;
}
.hux-memory-meta {
display: grid;
font-size: 0.68rem;
gap: 0.35rem 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
margin: 0.75rem 0;
}
.hux-memory-meta div { min-width: 0; }
.hux-memory-meta dt { color: color-mix(in srgb, currentColor 52%, transparent); }
.hux-memory-meta dd { margin: 0.12rem 0 0; overflow-wrap: anywhere; }
.hux-memory-actions {
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
}
.hux-memory-actions svg,
.hux-memory-heading svg,
.hux-memory-hidden svg,
.hux-memory-approval svg {
height: 0.9rem;
width: 0.9rem;
}
.hux-memory-edit {
display: grid;
gap: 0.4rem;
}
.hux-memory-edit textarea {
min-height: 6rem;
padding: 0.55rem;
resize: vertical;
width: 100%;
}
.hux-memory-warning {
border-left: 2px solid #efb756;
color: #efcf96;
font-size: 0.72rem;
margin: 0;
padding-left: 0.6rem;
}
.hux-memory-empty {
color: color-mix(in srgb, currentColor 58%, transparent);
font-size: 0.78rem;
text-align: center;
}
@media (max-width: 34rem) {
.hux-memory-heading,
.hux-memory-approval { align-items: stretch; }
.hux-memory-approval { max-width: none; width: 100%; }
.hux-memory-meta { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.hux-memory * { scroll-behavior: auto; transition: none; }
}

View File

@ -0,0 +1,126 @@
/** Safe frontend contracts for the default-off HUX-02 memory ledger. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type MemoryKind = "preference" | "fact" | "instruction" | "context";
export type MemoryStatus =
| "proposed"
| "active"
| "rejected"
| "expired"
| "forgotten";
export type Sensitivity = "public" | "personal" | "sensitive" | "restricted";
export type MemoryTopic =
| "general"
| "health"
| "finance"
| "legal"
| "relationships"
| "credentials"
| "minors"
| "location"
| "biometric";
export interface HuxIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface RawMemoryEntry {
schema?: unknown;
id?: unknown;
owner?: unknown;
scope?: unknown;
kind?: unknown;
content?: unknown;
status?: unknown;
approval_mode?: unknown;
sensitivity?: unknown;
topic?: unknown;
ttl?: unknown;
source?: unknown;
provenance?: unknown;
created_at?: unknown;
updated_at?: unknown;
audit?: unknown;
}
export interface RawMemoryPage {
schema?: unknown;
identity?: unknown;
entries?: unknown;
next_cursor?: unknown;
}
export interface MemoryItem {
id: string;
kind: MemoryKind;
content: string | null;
contentNotice: string | null;
status: MemoryStatus;
sensitivity: Sensitivity;
topic: MemoryTopic;
approvalMode: "automatic" | "ask";
scopeLabel: string;
sourceLabel: string;
sourceIsMessage: boolean;
provenanceLabel: string;
reason: string;
savedAt: string;
updatedAt: string;
ttlLabel: string;
}
export interface MemoryPage {
entries: MemoryItem[];
invalid: number;
truncated: number;
nextCursor: string | null;
}
export type DoNotRememberTarget =
| { kind: "message"; messageId: string }
| { kind: "conversation"; conversationId: string }
| { kind: "topic"; topic: MemoryTopic };
export type MemoryAction = "approve" | "reject" | "forget";
export interface MemoryEndpoint {
method: "GET" | "POST" | "PUT";
path: string;
responseSchema: string;
}
export interface MemoryEndpointContract {
identity: HuxIdentity;
list: MemoryEndpoint;
suggestOnly: MemoryEndpoint;
doNotRemember: MemoryEndpoint;
action(memoryId: string, action: MemoryAction): MemoryEndpoint;
revise(memoryId: string): MemoryEndpoint;
}
/** The server compares expectedIdentity with the authenticated HUX capability scope. */
export interface MemoryMutationRequest {
expectedIdentity: HuxIdentity;
expectedUpdatedAt: string;
action: MemoryAction;
}
/** A revision appends a proposed entry; it never overwrites the referenced entry. */
export interface MemoryRevisionRequest {
expectedIdentity: HuxIdentity;
expectedUpdatedAt: string;
content: string;
approvalMode: "ask";
}
export interface MemoryBlockRequest {
expectedIdentity: HuxIdentity;
target: DoNotRememberTarget;
}
export interface SuggestOnlyRequest {
expectedIdentity: HuxIdentity;
approvalMode: "ask";
}

View File

@ -0,0 +1,187 @@
/** Accessible HUX-06 mode intent selector. The feature is default-off. */
import { useMemo } from "react";
import {
buildModeRequest,
friendlyModesEnabled,
normalizeAdvancedRoutes,
normalizeModeCatalog,
normalizeResolvedRoute,
} from "./model.ts";
import type {
FriendlyModeIntent,
FriendlyModeName,
RawAdvancedRouteChoice,
RawModeIntent,
RawResolvedRoute,
} from "./types.ts";
interface FriendlyModeSelectorProps {
flags?: Iterable<string>;
modes: readonly RawModeIntent[];
selectedMode: FriendlyModeName;
advancedRoutes?: readonly RawAdvancedRouteChoice[];
resolvedRoute?: RawResolvedRoute;
disabled?: boolean;
onChange(intent: FriendlyModeIntent): void;
}
const MODE_HELP: Readonly<Record<FriendlyModeName, string>> = {
fast: "Quick, direct answers with light reasoning.",
thoughtful: "More reasoning for careful decisions.",
research: "Source-backed investigation with citations.",
create: "Generative work with artifact support.",
private: "Local-only processing with ephemeral retention.",
};
const PROVIDER_LABELS = {
codex: "OpenAI",
claude: "Anthropic",
local: "Local",
} as const;
/** Render nothing unless HUX-11 and HUX-06 are both explicitly enabled. */
export function FriendlyModeSelector({
flags,
modes,
selectedMode,
advancedRoutes = [],
resolvedRoute,
disabled = false,
onChange,
}: FriendlyModeSelectorProps) {
const enabled = friendlyModesEnabled(flags);
const catalog = useMemo(() => normalizeModeCatalog(modes), [modes]);
const choices = useMemo(
() => normalizeAdvancedRoutes(advancedRoutes),
[advancedRoutes],
);
const resolved = useMemo(
() => normalizeResolvedRoute(resolvedRoute),
[resolvedRoute],
);
if (!enabled) return null;
if (!catalog) {
return (
<p className="hux-modes-error" role="alert">
Mode choices are unavailable because their safety contract could not be
verified.
</p>
);
}
const selected = catalog.find((mode) => mode.mode === selectedMode);
if (!selected) {
return (
<p className="hux-modes-error" role="alert">
The selected mode could not be verified. No routing change was made.
</p>
);
}
const compatibleChoices = choices.filter((choice) =>
selected.mode === "private"
? choice.localOnly && choice.provider === "local"
: !choice.localOnly && selected.constraints.providers.includes(choice.provider),
);
return (
<section className="hux-modes" aria-labelledby="hux-modes-heading">
<div className="hux-modes-heading">
<div>
<h2 id="hux-modes-heading">Response mode</h2>
<p>Choose the intent; Switchyard selects from the allowed pool.</p>
</div>
<span className="hux-modes-active" aria-live="polite">
{selected.label}
</span>
</div>
<fieldset disabled={disabled} className="hux-mode-grid">
<legend className="sr-only">Choose a response mode</legend>
{catalog.map((mode) => (
<label className="hux-mode-card" key={mode.mode}>
<input
type="radio"
name="hermes-friendly-mode"
value={mode.mode}
checked={mode.mode === selected.mode}
aria-describedby={`hux-mode-help-${mode.mode}`}
onChange={() => {
const request = buildModeRequest(mode);
if (request) onChange(request);
}}
/>
<span className="hux-mode-card-copy">
<strong>{mode.label}</strong>
<span id={`hux-mode-help-${mode.mode}`}>{MODE_HELP[mode.mode]}</span>
</span>
</label>
))}
</fieldset>
<details className="hux-modes-advanced">
<summary>Advanced routing details</summary>
<div className="hux-modes-advanced-panel">
<dl>
<div>
<dt>Allowed pools</dt>
<dd>
{selected.constraints.providers
.map((provider) => PROVIDER_LABELS[provider])
.join(" + ")}
</dd>
</div>
<div>
<dt>Requested route</dt>
<dd>{selected.switchyard.route_id}</dd>
</div>
{resolved?.target && (
<div>
<dt>Resolved route</dt>
<dd>{resolved.target}</dd>
</div>
)}
{resolved?.provider && (
<div>
<dt>Resolved provider</dt>
<dd>{PROVIDER_LABELS[resolved.provider]}</dd>
</div>
)}
{resolved?.effort && (
<div>
<dt>Resolved effort</dt>
<dd>{resolved.effort}</dd>
</div>
)}
</dl>
<label className="hux-route-pin">
<span>Exact route pin</span>
<select
disabled={disabled}
defaultValue=""
onChange={(event) => {
const request = event.currentTarget.value
? buildModeRequest(selected, event.currentTarget.value, choices)
: buildModeRequest(selected);
if (request) onChange(request);
}}
>
<option value="">Automatic — use all allowed pools</option>
{compatibleChoices.map((choice) => (
<option key={choice.routeId} value={choice.routeId}>
{choice.label} — {PROVIDER_LABELS[choice.provider]}
</option>
))}
</select>
</label>
<p className="hux-route-pin-help">
Pinning bypasses automatic selection for this choice. Automatic
modes otherwise keep both OpenAI and Anthropic eligible.
</p>
</div>
</details>
</section>
);
}

View File

@ -0,0 +1,26 @@
/** Public integration surface for the inert HUX-06 frontend module. */
export { FriendlyModeSelector } from "./FriendlyModeSelector.tsx";
export {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
MODE_ORDER,
buildModeRequest,
createFriendlyModesAdapter,
friendlyModesEnabled,
normalizeAdvancedRoutes,
normalizeMode,
normalizeModeCatalog,
normalizeResolvedRoute,
} from "./model.ts";
export type {
AdvancedRouteChoice,
FriendlyModeIntent,
FriendlyModeName,
FriendlyModesAdapter,
HuxFoundationClient,
RawAdvancedRouteChoice,
RawModeIntent,
RawResolvedRoute,
ResolvedRouteDisclosure,
} from "./types.ts";

View File

@ -0,0 +1,312 @@
/** Fail-closed HUX-06 intent validation and HUX foundation adapter. */
import type {
AdvancedRouteChoice,
FriendlyModeIntent,
FriendlyModeName,
FriendlyModesAdapter,
HuxFoundationClient,
ModeConstraints,
ModeEffort,
ModeProvider,
RawAdvancedRouteChoice,
RawModeIntent,
RawResolvedRoute,
ResolvedRouteDisclosure,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
export const MODE_ORDER: readonly FriendlyModeName[] = [
"fast",
"thoughtful",
"research",
"create",
"private",
];
const MODE_LABELS: Readonly<Record<FriendlyModeName, string>> = {
fast: "Fast",
thoughtful: "Thoughtful",
research: "Research",
create: "Create",
private: "Private",
};
const PROVIDERS = new Set<ModeProvider>(["codex", "claude", "local"]);
const EFFORTS: readonly ModeEffort[] = ["low", "medium", "high", "xhigh"];
const ROUTE = /^atlas\/(auto|manual|fallback|worker)\/[a-z0-9/-]+$/;
const MANUAL_ROUTE = /^atlas\/manual\/[a-z0-9/-]+$/;
const REMOTE_PROVIDER_NAME = /\b(?:anthropic|claude|codex|openai)\b/i;
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const allowed = new Set([...required, ...optional]);
return (
required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key))
);
}
function boundedString(value: unknown, min: number, max: number): value is string {
return typeof value === "string" && value.length >= min && value.length <= max;
}
function oneOf<T extends string>(value: unknown, values: readonly T[]): value is T {
return typeof value === "string" && values.includes(value as T);
}
function normalizeConstraints(value: unknown): ModeConstraints | null {
const constraints = record(value);
const required = [
"providers",
"local_only",
"effort",
"tools",
"memory",
"citations_required",
"retention",
];
if (!constraints || !exactKeys(constraints, required)) return null;
if (!Array.isArray(constraints.providers) || constraints.providers.length < 1)
return null;
const providers = constraints.providers;
if (
providers.some((provider) => !PROVIDERS.has(provider as ModeProvider)) ||
new Set(providers).size !== providers.length ||
typeof constraints.local_only !== "boolean"
)
return null;
const effort = record(constraints.effort);
const tools = record(constraints.tools);
const memory = record(constraints.memory);
if (
!effort ||
!exactKeys(effort, ["min", "max"]) ||
!oneOf(effort.min, EFFORTS) ||
!oneOf(effort.max, EFFORTS) ||
EFFORTS.indexOf(effort.min) > EFFORTS.indexOf(effort.max)
)
return null;
if (
!tools ||
!exactKeys(tools, ["web", "shell", "artifacts", "delegate"]) ||
!oneOf(tools.web, ["required", "allowed", "denied"] as const) ||
!oneOf(tools.shell, ["allowed", "denied"] as const) ||
!oneOf(tools.artifacts, ["encouraged", "allowed", "denied"] as const) ||
!oneOf(tools.delegate, ["allowed", "denied"] as const)
)
return null;
if (
!memory ||
!exactKeys(memory, ["read", "write"]) ||
typeof memory.read !== "boolean" ||
typeof memory.write !== "boolean" ||
typeof constraints.citations_required !== "boolean" ||
!oneOf(constraints.retention, ["default", "ephemeral"] as const)
)
return null;
// Provider arrays are semantically sets. Canonical ordering prevents their
// transport order from becoming an accidental routing preference.
const canonicalProviders = (["codex", "claude", "local"] as const).filter(
(provider) => providers.includes(provider),
);
return {
providers: [...canonicalProviders],
local_only: constraints.local_only,
effort: { min: effort.min, max: effort.max },
tools: {
web: tools.web,
shell: tools.shell,
artifacts: tools.artifacts,
delegate: tools.delegate,
},
memory: { read: memory.read, write: memory.write },
citations_required: constraints.citations_required,
retention: constraints.retention,
};
}
function modeSemanticsAreSafe(
mode: FriendlyModeName,
constraints: ModeConstraints,
routeId: string,
): boolean {
const providers = new Set(constraints.providers);
if (mode === "private") {
return (
constraints.local_only &&
providers.size === 1 &&
providers.has("local") &&
routeId.startsWith("atlas/auto/")
);
}
return (
!constraints.local_only &&
providers.has("codex") &&
providers.has("claude") &&
!REMOTE_PROVIDER_NAME.test(routeId.replaceAll("/", " ")) &&
routeId.startsWith("atlas/auto/")
);
}
export function friendlyModesEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return enabled.has(FOUNDATION_FLAG) && enabled.has(FRIENDLY_MODES_FLAG);
}
export function normalizeMode(raw: RawModeIntent): FriendlyModeIntent | null {
const root = record(raw);
if (
!root ||
!exactKeys(root, ["schema", "mode", "label", "intent", "constraints", "switchyard"]) ||
root.schema !== "hux.mode.v1" ||
!oneOf(root.mode, MODE_ORDER) ||
root.label !== MODE_LABELS[root.mode] ||
!boundedString(root.intent, 1, 400) ||
REMOTE_PROVIDER_NAME.test(root.intent)
)
return null;
const constraints = normalizeConstraints(root.constraints);
const switchyard = record(root.switchyard);
if (
!constraints ||
!switchyard ||
!exactKeys(switchyard, ["route_id"]) ||
typeof switchyard.route_id !== "string" ||
!ROUTE.test(switchyard.route_id) ||
!modeSemanticsAreSafe(root.mode, constraints, switchyard.route_id)
)
return null;
return {
schema: "hux.mode.v1",
mode: root.mode,
label: root.label,
intent: root.intent,
constraints,
switchyard: { route_id: switchyard.route_id },
};
}
export function normalizeModeCatalog(raw: unknown): FriendlyModeIntent[] | null {
if (!Array.isArray(raw) || raw.length !== MODE_ORDER.length) return null;
const modes = raw.map((value) => normalizeMode(value as RawModeIntent));
if (modes.some((mode) => mode === null)) return null;
const byName = new Map(
(modes as FriendlyModeIntent[]).map((mode) => [mode.mode, mode]),
);
if (byName.size !== MODE_ORDER.length) return null;
return MODE_ORDER.map((name) => byName.get(name) as FriendlyModeIntent);
}
export function normalizeAdvancedRoutes(raw: unknown): AdvancedRouteChoice[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const choices: AdvancedRouteChoice[] = [];
for (const value of raw.slice(0, 100)) {
const choice = record(value as RawAdvancedRouteChoice);
if (
!choice ||
!exactKeys(choice, ["route_id", "label", "provider", "effort", "local_only"]) ||
typeof choice.route_id !== "string" ||
!MANUAL_ROUTE.test(choice.route_id) ||
seen.has(choice.route_id) ||
!boundedString(choice.label, 1, 80) ||
!PROVIDERS.has(choice.provider as ModeProvider) ||
!oneOf(choice.effort, EFFORTS) ||
typeof choice.local_only !== "boolean"
)
continue;
seen.add(choice.route_id);
choices.push({
routeId: choice.route_id,
label: choice.label,
provider: choice.provider as ModeProvider,
effort: choice.effort,
localOnly: choice.local_only,
});
}
return choices;
}
export function buildModeRequest(
mode: FriendlyModeIntent,
advancedRouteId?: string,
choices: readonly AdvancedRouteChoice[] = [],
): FriendlyModeIntent | null {
const normalized = normalizeMode(mode);
if (!normalized) return null;
if (advancedRouteId === undefined) return normalized;
const choice = choices.find((item) => item.routeId === advancedRouteId);
if (!choice || !MANUAL_ROUTE.test(advancedRouteId)) return null;
const allowed = normalized.constraints.providers.includes(choice.provider);
const privateSafe =
normalized.mode !== "private" ||
(choice.localOnly && choice.provider === "local");
const automaticSafe =
normalized.mode === "private" || (!choice.localOnly && allowed);
if (!allowed || !privateSafe || !automaticSafe) return null;
return {
...normalized,
switchyard: {
route_id: normalized.switchyard.route_id,
override_route_id: advancedRouteId,
},
};
}
export function normalizeResolvedRoute(raw: unknown): ResolvedRouteDisclosure | null {
const route = record(raw as RawResolvedRoute);
if (!route || !exactKeys(route, ["requested"], ["resolved_target", "provider", "effort"]))
return null;
if (!boundedString(route.requested, 1, 120)) return null;
if (
route.resolved_target !== undefined &&
!boundedString(route.resolved_target, 1, 120)
)
return null;
if (route.provider !== undefined && !PROVIDERS.has(route.provider as ModeProvider))
return null;
if (route.effort !== undefined && !oneOf(route.effort, EFFORTS)) return null;
return {
requested: route.requested,
target: route.resolved_target as string | undefined,
provider: route.provider as ModeProvider | undefined,
effort: route.effort as ModeEffort | undefined,
};
}
export function createFriendlyModesAdapter(
foundation: HuxFoundationClient,
): FriendlyModesAdapter {
if (
!foundation ||
foundation.apiVersion !== "hux.v1" ||
typeof foundation.enabled !== "function" ||
typeof foundation.endpoint !== "function"
)
throw new TypeError("A hux.v1 foundation client is required");
return Object.freeze({
apiVersion: "hux.v1" as const,
enabled: () =>
foundation.enabled(FOUNDATION_FLAG) && foundation.enabled(FRIENDLY_MODES_FLAG),
endpoint: () => foundation.endpoint("/modes"),
adaptCatalog: normalizeModeCatalog,
adaptResolvedRoute: normalizeResolvedRoute,
buildRequest: (
mode: FriendlyModeIntent,
advancedRouteId?: string,
choices?: readonly AdvancedRouteChoice[],
) => buildModeRequest(mode, advancedRouteId, choices),
});
}

View File

@ -0,0 +1,178 @@
/* Scoped, inert-until-imported HUX-06 presentation. */
.hux-modes {
color: inherit;
container-type: inline-size;
}
.hux-modes-heading {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.hux-modes-heading h2 {
font-size: 0.875rem;
font-weight: 650;
margin: 0;
}
.hux-modes-heading p,
.hux-route-pin-help {
color: color-mix(in srgb, currentColor 58%, transparent);
font-size: 0.6875rem;
line-height: 1.45;
margin: 0.2rem 0 0;
}
.hux-modes-active {
background: color-mix(in srgb, #22d3ee 12%, transparent);
border: 1px solid color-mix(in srgb, #22d3ee 34%, transparent);
border-radius: 999px;
color: color-mix(in srgb, currentColor 90%, #22d3ee);
font-size: 0.6875rem;
font-weight: 650;
padding: 0.25rem 0.55rem;
white-space: nowrap;
}
.hux-mode-grid {
border: 0;
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin: 0.75rem 0 0;
padding: 0;
}
.hux-mode-card {
cursor: pointer;
min-width: 0;
position: relative;
}
.hux-mode-card input {
height: 1px;
opacity: 0;
position: absolute;
width: 1px;
}
.hux-mode-card-copy {
background: color-mix(in srgb, currentColor 3%, transparent);
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.7rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 4.35rem;
padding: 0.6rem;
transition: border-color 140ms ease, background-color 140ms ease;
}
.hux-mode-card-copy strong {
font-size: 0.75rem;
}
.hux-mode-card-copy > span {
color: color-mix(in srgb, currentColor 60%, transparent);
font-size: 0.625rem;
line-height: 1.35;
}
.hux-mode-card input:checked + .hux-mode-card-copy {
background: color-mix(in srgb, #22d3ee 8%, transparent);
border-color: color-mix(in srgb, #22d3ee 62%, currentColor);
}
.hux-mode-card input:focus-visible + .hux-mode-card-copy,
.hux-route-pin select:focus-visible,
.hux-modes-advanced summary:focus-visible {
outline: 2px solid #22d3ee;
outline-offset: 2px;
}
.hux-mode-grid:disabled .hux-mode-card {
cursor: not-allowed;
opacity: 0.55;
}
.hux-modes-advanced {
border-top: 1px solid color-mix(in srgb, currentColor 11%, transparent);
font-size: 0.75rem;
margin-top: 0.75rem;
padding-top: 0.6rem;
}
.hux-modes-advanced summary {
cursor: pointer;
font-weight: 600;
}
.hux-modes-advanced-panel {
display: grid;
gap: 0.7rem;
margin-top: 0.65rem;
}
.hux-modes-advanced dl {
display: grid;
gap: 0.35rem;
margin: 0;
}
.hux-modes-advanced dl div {
display: grid;
gap: 0.5rem;
grid-template-columns: minmax(7rem, 0.35fr) 1fr;
}
.hux-modes-advanced dt {
color: color-mix(in srgb, currentColor 56%, transparent);
}
.hux-modes-advanced dd {
margin: 0;
overflow-wrap: anywhere;
}
.hux-route-pin {
display: grid;
gap: 0.3rem;
}
.hux-route-pin span {
font-size: 0.6875rem;
font-weight: 650;
}
.hux-route-pin select {
background: color-mix(in srgb, currentColor 4%, transparent);
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.55rem;
color: inherit;
font: inherit;
max-width: 100%;
padding: 0.5rem 0.6rem;
}
.hux-modes-error {
border: 1px solid color-mix(in srgb, #fb7185 50%, transparent);
border-radius: 0.65rem;
color: color-mix(in srgb, currentColor 88%, #fb7185);
font-size: 0.75rem;
margin: 0;
padding: 0.65rem 0.75rem;
}
@container (max-width: 42rem) {
.hux-mode-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.hux-mode-card-copy {
transition: none;
}
}

View File

@ -0,0 +1,96 @@
/** Safe frontend types for the opt-in HUX-06 friendly mode selector. */
export type FriendlyModeName =
| "fast"
| "thoughtful"
| "research"
| "create"
| "private";
export type ModeProvider = "codex" | "claude" | "local";
export type ModeEffort = "low" | "medium" | "high" | "xhigh";
export interface RawModeIntent {
schema?: unknown;
mode?: unknown;
label?: unknown;
intent?: unknown;
constraints?: unknown;
switchyard?: unknown;
}
export interface ModeConstraints {
providers: ModeProvider[];
local_only: boolean;
effort: { min: ModeEffort; max: ModeEffort };
tools: {
web: "required" | "allowed" | "denied";
shell: "allowed" | "denied";
artifacts: "encouraged" | "allowed" | "denied";
delegate: "allowed" | "denied";
};
memory: { read: boolean; write: boolean };
citations_required: boolean;
retention: "default" | "ephemeral";
}
export interface FriendlyModeIntent {
schema: "hux.mode.v1";
mode: FriendlyModeName;
label: string;
intent: string;
constraints: ModeConstraints;
switchyard: {
route_id: string;
override_route_id?: string;
};
}
export interface RawResolvedRoute {
requested?: unknown;
resolved_target?: unknown;
provider?: unknown;
effort?: unknown;
}
export interface ResolvedRouteDisclosure {
requested: string;
target?: string;
provider?: ModeProvider;
effort?: ModeEffort;
}
export interface RawAdvancedRouteChoice {
route_id?: unknown;
label?: unknown;
provider?: unknown;
effort?: unknown;
local_only?: unknown;
}
export interface AdvancedRouteChoice {
routeId: string;
label: string;
provider: ModeProvider;
effort: ModeEffort;
localOnly: boolean;
}
export interface HuxFoundationClient {
apiVersion: string;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
export interface FriendlyModesAdapter {
apiVersion: "hux.v1";
enabled(): boolean;
endpoint(): string;
adaptCatalog(raw: unknown): FriendlyModeIntent[] | null;
adaptResolvedRoute(raw: unknown): ResolvedRouteDisclosure | null;
buildRequest(
mode: FriendlyModeIntent,
advancedRouteId?: string,
choices?: readonly AdvancedRouteChoice[],
): FriendlyModeIntent | null;
}

View File

@ -0,0 +1,166 @@
/** Accessible multimodal UI. It emits intents but never fetches, uploads, or opens devices. */
import { Camera, FileText, Image as ImageIcon, Mic2, MonitorUp, Pencil, Upload } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import {
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
} from "./model.ts";
import { validateUpload } from "./security.ts";
import type { FileCandidate, MediaView, MultimodalProps } from "./types.ts";
function MediaPreview({item}: {item: MediaView}) {
if (item.kind === "image" && item.previewUrl) return <figure className="hux-mm-image">
<img src={item.previewUrl} alt={item.altText} />
<figcaption>{item.fileName} · version {item.artifactVersion}</figcaption>
</figure>;
if (item.kind === "document" && item.previewText) return <div className="hux-mm-document">
<strong>{item.fileName}</strong>
<pre tabIndex={0}><code>{item.previewText}</code></pre>
<small>Safe text preview; active document content is not executed.</small>
</div>;
const Icon = item.kind === "image" ? ImageIcon : item.kind === "audio" ? Mic2 : FileText;
return <div className="hux-mm-safe-preview" role="img" aria-label={`${item.kind} preview unavailable`}>
<Icon aria-hidden />
<strong>{item.fileName}</strong>
<span>{item.mime} · {Math.ceil(item.bytes / 1024)} KB</span>
<small>Content is not executed. Open an authorized artifact preview to inspect it.</small>
</div>;
}
function UploadPanel({props, authorization, setNotice}: {
props: MultimodalProps;
authorization: MultimodalProps["page"]["upload_authorization"];
setNotice: (message: string) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const authorized = authorization === "authorized";
function handle(files: FileList | readonly File[]) {
const accepted: FileCandidate[] = [];
const rejected: string[] = [];
for (const file of Array.from(files).slice(0, 20)) {
const result = validateUpload(file, String(authorization));
if (result.ok) accepted.push(result.file);
else rejected.push(`${file.name || "File"}: ${result.reason}`);
}
if (accepted.length) props.onUpload?.(accepted);
setNotice(rejected.length ? rejected.join(" ") : `${accepted.length} file${accepted.length === 1 ? "" : "s"} ready to upload.`);
}
return <section className="hux-mm-upload" aria-labelledby="hux-mm-upload-title"
onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.preventDefault(); handle(event.dataTransfer.files);
}}>
<Upload aria-hidden />
<h3 id="hux-mm-upload-title">Add files or images</h3>
<p>Drop authorized PNG, JPEG, WebP, PDF, text, Markdown, or audio files here.</p>
<input ref={input} className="sr-only" type="file" multiple
accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,text/markdown,audio/webm,audio/wav,audio/mpeg,audio/ogg"
onChange={(event) => event.currentTarget.files && handle(event.currentTarget.files)} />
{authorized ? <button type="button" disabled={props.busy} onClick={() => input.current?.click()}>
Choose files
</button> : <button type="button" disabled={props.busy || authorization === "denied"}
onClick={() => props.onRequestAuthorization?.("upload")}>
{authorization === "denied" ? "Uploads denied" : "Ask before uploading"}
</button>}
</section>;
}
function MediaEditor({item, props}: {item: MediaView; props: MultimodalProps}) {
const [draft, setDraft] = useState("");
const [page, setPage] = useState("");
if (item.kind === "audio") return null;
const image = item.kind === "image";
return <form className="hux-mm-editor" onSubmit={(event) => {
event.preventDefault();
if (image) props.onImageEdit?.(buildImageEdit(item, props.scope, draft));
else props.onAnnotate?.(buildAnnotation(item, props.scope, draft, page ? Number(page) : null));
setDraft(""); setPage("");
}}>
<label htmlFor={`hux-mm-edit-${item.id}`}>{image ? "Describe an image edit" : "Add a document annotation"}</label>
<textarea id={`hux-mm-edit-${item.id}`} value={draft} maxLength={image ? 2000 : 1000}
onChange={(event) => setDraft(event.currentTarget.value)} required />
{!image && <label>Page (optional)<input type="number" min="1" max="100000" value={page}
onChange={(event) => setPage(event.currentTarget.value)} /></label>}
<button type="submit" disabled={props.busy || !draft.trim()}>
<Pencil aria-hidden /> {image ? "Create linked variant" : "Save annotation intent"}
</button>
{image && <small>The next version retains artifact and variant lineage.</small>}
</form>;
}
function TranscriptEditor({props, page}: {props: MultimodalProps; page: ReturnType<typeof normalizeMultimodalPage>}) {
const transcript = page?.transcript;
const [text, setText] = useState(transcript?.text || "");
if (!transcript) return <p>No voice transcript is attached to this turn.</p>;
return <form className="hux-mm-transcript" onSubmit={(event) => {
event.preventDefault();
props.onCorrectTranscript?.(buildTranscriptCorrection(transcript, text, props.identity, props.scope));
}}>
<label htmlFor="hux-mm-transcript-text">Voice transcript · {transcript.language}</label>
<textarea id="hux-mm-transcript-text" value={text} maxLength={20000}
readOnly={!transcript.finalized} onChange={(event) => setText(event.currentTarget.value)} />
<button type="submit" disabled={props.busy || !transcript.finalized || !text.trim() || text === transcript.text}>
Correct transcript
</button>
{!transcript.finalized && <small>Correction unlocks when transcription is finalized.</small>}
</form>;
}
function CaptureButton({kind, props}: {kind: "camera" | "screen"; props: MultimodalProps}) {
const decision = captureAuthorization(props.autonomyPolicy, null, props.identity);
const Icon = kind === "camera" ? Camera : MonitorUp;
return <button type="button" disabled={props.busy || decision === "deny"} onClick={() => {
props.onCapture?.(buildCaptureIntent(kind, props.autonomyPolicy, props.identity, props.scope));
}}>
<Icon aria-hidden /> {decision === "deny" ? `${kind} denied` : `Ask to use ${kind}`}
</button>;
}
/** Render nothing unless foundation, projects, artifacts, autonomy, and multimodal are active. */
export function MultimodalChat(props: MultimodalProps) {
const enabled = multimodalEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeMultimodalPage(props.page, props.identity, props.scope) : null,
[enabled, props.identity, props.page, props.scope]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [notice, setNotice] = useState("");
if (!enabled || !page) return null;
const selected = page.media.find((item) => item.id === selectedId) || page.media[0] || null;
return <section className="hux-multimodal" aria-label="Multimodal chat" aria-busy={props.busy}>
<header><div><span className="hux-mm-kicker">HUX-07</span><h2>Multimodal workspace</h2>
<p>Files, voice, and generated variants remain bound to this conversation.</p></div>
<div className="hux-mm-capture" aria-label="Live media permissions">
<CaptureButton kind="camera" props={props} /><CaptureButton kind="screen" props={props} />
</div>
</header>
{props.error && <p className="hux-mm-error" role="alert">{props.error}</p>}
{page.rejected > 0 && <p className="hux-mm-warning" role="status">
Some media was withheld because its owner, binding, type, or lineage was invalid.
</p>}
<UploadPanel props={props} authorization={page.uploadAuthorization} setNotice={setNotice} />
<div className="hux-mm-workspace">
<nav aria-label="Conversation media"><ul>{page.media.map((item) => <li key={item.id}>
<button type="button" aria-current={selected?.id === item.id ? "page" : undefined}
onClick={() => setSelectedId(item.id)}>{item.kind === "image" ? <ImageIcon aria-hidden /> :
<FileText aria-hidden />}<span>{item.fileName}</span></button>
</li>)}</ul>{!page.media.length && <p>No media is attached.</p>}</nav>
<main>{selected ? <><MediaPreview item={selected} /><MediaEditor key={selected.id} item={selected} props={props} /></> :
<p>Select media to preview or annotate it.</p>}</main>
<aside aria-label="Generation history"><h3>Variant history</h3>
{page.threads.map((thread) => <section key={thread.id}><h4>{thread.prompt}</h4><ol>
{thread.variants.map((variant, index) => <li key={variant.id}>
Variant {index + 1} · artifact v{variant.artifactVersion}
{variant.parentVariantId && <small>Linked to an earlier variant</small>}
</li>)}</ol></section>)}
{!page.threads.length && <p>No generated variants yet.</p>}
</aside>
</div>
<TranscriptEditor key={`${page.transcript?.id || "none"}:${page.transcript?.revision || 0}`}
props={props} page={page} />
<p className="sr-only" role="status" aria-live="polite">{notice ||
`${page.media.length} media items and ${page.threads.length} generation threads available`}</p>
</section>;
}

View File

@ -0,0 +1,61 @@
/** Same-origin endpoint descriptors for the future HUX-07 backend. */
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import { multimodalEnabled } from "./model.ts";
import { normalizeIdentity, normalizeScope, safeEndpointPart, sameIdentity, sameScope } from "./security.ts";
import type {
CaptureKind, MultimodalEndpoint, MultimodalEndpointContract,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
endpoint(path: string): string;
}
function endpoint(method: MultimodalEndpoint["method"], path: string,
responseSchema: string, requiredBody?: readonly string[]): MultimodalEndpoint {
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
}
/** Describe endpoints only. The caller owns authenticated transport and approval execution. */
export function createMultimodalEndpointContract(client: FoundationClient, scope: ArtifactScope,
flags?: Iterable<string>): MultimodalEndpointContract | null {
if (!multimodalEnabled(flags)) return null;
if (client.apiVersion !== "hux.v1" || !sameIdentity(normalizeIdentity(client.identity), client.identity) ||
!sameScope(normalizeScope(scope), scope)) throw new TypeError("Scoped HUX v1 client required");
const base = client.endpoint(`/projects/${safeEndpointPart(scope.projectId, "prj")}` +
`/conversations/${safeEndpointPart(scope.conversationId, "conv")}/multimodal`);
const child = (root: string, id: unknown, prefix: string, suffix = "") =>
`${base}/${root}/${safeEndpointPart(id, prefix)}${suffix}`;
return Object.freeze({
page: endpoint("GET", base, "hux.multimodal_page.v1"),
beginUpload: endpoint("POST", `${base}/uploads`, "hux.media_upload.v1",
["file_name", "mime", "bytes", "authorization_id"]),
finalizeUpload(uploadId) {
return endpoint("POST", child("uploads", uploadId, "upl", "/finalize"), "hux.media.v1",
["content_ref", "expected_upload_id"]);
},
editImage(mediaId) {
return endpoint("POST", child("media", mediaId, "med", "/edits"), "hux.media.v1",
["artifact_id", "expected_current_version", "instruction", "lineage"]);
},
annotate(mediaId) {
return endpoint("POST", child("media", mediaId, "med", "/annotations"), "hux.media.v1",
["artifact_id", "expected_current_version", "page", "note"]);
},
correctTranscript(transcriptId) {
return endpoint("POST", child("transcripts", transcriptId, "trn", "/corrections"),
"hux.voice_transcript.v1", ["turn_id", "expected_revision", "corrected_text"]);
},
requestCapture(kind: CaptureKind) {
if (!['camera', 'screen'].includes(kind)) throw new TypeError("Capture kind is invalid");
return endpoint("POST", `${base}/captures/${kind}/approvals`, "hux.approval.v1",
["capability", "required_decision", "conversation_id"]);
},
createVariant(threadId) {
return endpoint("POST", child("threads", threadId, "thr", "/variants"),
"hux.generation_thread.v1", ["media_id", "artifact_id", "artifact_version", "parent_variant_id"]);
},
});
}

View File

@ -0,0 +1,8 @@
export { MultimodalChat } from "./MultimodalChat.tsx";
export { createMultimodalEndpointContract } from "./endpoints.ts";
export {
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
} from "./model.ts";
export { validateUpload } from "./security.ts";
export type * from "./types.ts";

View File

@ -0,0 +1,192 @@
/** Fail-closed multimodal normalization and intent builders for HUX-07. */
import { actionAuthorization } from "../autonomy/model.ts";
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import type { ApprovalView, PolicyView } from "../autonomy/types.ts";
import {
isOpaqueId, isUtc, normalizeIdentity, normalizeScope, safeBlobUrl, safePreviewText, safeText,
sameIdentity, sameScope,
} from "./security.ts";
import { validateUpload } from "./security.ts";
import type {
AnnotationIntent, CaptureIntent, CaptureKind, GenerationThread, GenerationVariant,
ImageEditIntent, MediaKind, MediaView, MultimodalPage, RawMediaEnvelope,
TranscriptCorrectionIntent, TranscriptView,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
export const ARTIFACTS_FLAG = "hux.artifacts";
export const AUTONOMY_FLAG = "hux.autonomy";
export const MULTIMODAL_FLAG = "hux.multimodal";
export const REQUIRED_FLAGS = Object.freeze([
FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG, AUTONOMY_FLAG, MULTIMODAL_FLAG,
]);
export function multimodalEnabled(flags?: Iterable<string>): boolean {
const active = new Set(flags || []);
return REQUIRED_FLAGS.every((flag) => active.has(flag));
}
function normalizeAnnotation(raw: unknown): MediaView["annotations"][number] | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const page = value.page === null ? null : value.page;
const note = safeText(value.note, "", 1_000);
return isOpaqueId(value.id, "ann") && (page === null ||
(Number.isSafeInteger(page) && (page as number) > 0 && (page as number) <= 100_000)) && note ?
{id: value.id, page: page as number | null, note} : null;
}
function normalizeMedia(raw: unknown, owner: string, scope: ArtifactScope): MediaView | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const kind = value.kind as MediaKind;
const fileName = safeText(value.file_name, "", 180);
const altText = safeText(value.alt_text, "Preview", 500);
const upload = validateUpload({name: fileName, type: String(value.mime), size: Number(value.bytes)}, "authorized");
if (value.schema !== "hux.media.v1" || !isOpaqueId(value.id, "med") || value.owner !== owner ||
value.project_id !== scope.projectId || value.conversation_id !== scope.conversationId ||
!isOpaqueId(value.artifact_id, "art") || !Number.isSafeInteger(value.artifact_version) ||
(value.artifact_version as number) < 1 || !["image", "document", "audio"].includes(kind) ||
!fileName || !Number.isSafeInteger(value.bytes) || !upload.ok || upload.kind !== kind ||
typeof value.mime !== "string" || !Array.isArray(value.annotations) || value.annotations.length > 500) return null;
const annotations = value.annotations.map(normalizeAnnotation);
if (annotations.some((item) => item === null)) return null;
const previewUrl = value.preview_url === null || value.preview_url === undefined ? null : safeBlobUrl(value.preview_url);
const previewText = value.preview_text === null || value.preview_text === undefined ? null : safePreviewText(value.preview_text);
if ((value.preview_url && (!previewUrl || value.preview_authorized !== true)) ||
(value.preview_text !== null && value.preview_text !== undefined && (!previewText || kind !== "document")) ||
(previewUrl && kind !== "image") || (previewText && value.preview_authorized !== true)) return null;
let lineage: MediaView["lineage"] = null;
if (value.lineage !== null && value.lineage !== undefined) {
const parent = value.lineage as Record<string, unknown> | null;
if (!parent || !isOpaqueId(parent.media_id, "med") || !isOpaqueId(parent.artifact_id, "art") ||
!Number.isSafeInteger(parent.artifact_version) || (parent.artifact_version as number) < 1 ||
parent.media_id === value.id) return null;
lineage = {mediaId: parent.media_id, artifactId: parent.artifact_id,
artifactVersion: parent.artifact_version as number};
}
return {id: value.id, owner, artifactId: value.artifact_id,
artifactVersion: value.artifact_version as number, kind, fileName, mime: value.mime,
bytes: value.bytes as number, previewUrl, previewText, altText,
annotations: annotations as MediaView["annotations"], lineage};
}
function normalizeVariant(raw: unknown, allowed: ReadonlyMap<string, MediaView>): GenerationVariant | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const media = allowed.get(String(value.media_id));
const parent = value.parent_variant_id;
if (!isOpaqueId(value.id, "var") || !media || value.artifact_id !== media.artifactId ||
value.artifact_version !== media.artifactVersion ||
!(parent === null || isOpaqueId(parent, "var")) || !isUtc(value.created_at)) return null;
return {id: value.id, mediaId: media.id, artifactId: media.artifactId,
artifactVersion: media.artifactVersion, parentVariantId: parent as string | null,
createdAt: value.created_at};
}
function normalizeThread(raw: unknown, media: ReadonlyMap<string, MediaView>, owner: string,
scope: ArtifactScope): GenerationThread | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const prompt = safeText(value.prompt, "", 2_000);
if (!isOpaqueId(value.id, "thr") || value.owner !== owner || value.project_id !== scope.projectId ||
value.conversation_id !== scope.conversationId || !prompt || !Array.isArray(value.variants) ||
value.variants.length < 1 || value.variants.length > 200) return null;
const variants = value.variants.map((item) => normalizeVariant(item, media));
if (variants.some((item) => item === null)) return null;
const ordered = variants as GenerationVariant[];
const ids = new Set<string>();
for (const variant of ordered) {
if (ids.has(variant.id) || (variant.parentVariantId && !ids.has(variant.parentVariantId))) return null;
ids.add(variant.id);
}
return {id: value.id, prompt, variants: ordered};
}
function normalizeTranscript(raw: unknown, owner: string, scope: ArtifactScope): TranscriptView | null {
if (raw === null || raw === undefined) return null;
if (typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const text = safeText(value.text, "", 20_000);
const language = safeText(value.language, "", 40);
return value.schema === "hux.voice_transcript.v1" && value.owner === owner &&
value.project_id === scope.projectId && value.conversation_id === scope.conversationId &&
isOpaqueId(value.id, "trn") &&
isOpaqueId(value.turn_id, "turn") && Number.isSafeInteger(value.revision) &&
(value.revision as number) >= 1 && Boolean(language) && Boolean(text) &&
typeof value.finalized === "boolean" ? {id: value.id, turnId: value.turn_id,
revision: value.revision as number, language, text, finalized: value.finalized} : null;
}
export function normalizeMultimodalPage(raw: RawMediaEnvelope, expected: HuxIdentity,
scope: ArtifactScope): MultimodalPage | null {
if (!raw || typeof raw !== "object" || raw.schema !== "hux.multimodal_page.v1" ||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), expected) ||
!sameScope(normalizeScope(raw.binding), scope) || !Array.isArray(raw.media) ||
raw.media.length > 200 || !Array.isArray(raw.threads) || raw.threads.length > 100) return null;
if (!["authorized", "requires_approval", "denied"].includes(String(
(raw as Record<string, unknown>).upload_authorization))) return null;
const normalized = raw.media.map((item) => normalizeMedia(item, expected.userRef, scope));
const ids = new Set<string>();
const unique = normalized.filter((item): item is MediaView => Boolean(item && !ids.has(item.id) && ids.add(item.id)));
const allMedia = new Map(unique.map((item) => [item.id, item]));
const media = unique.filter((item) => !item.lineage || (() => {
const parent = allMedia.get(item.lineage!.mediaId);
return Boolean(parent && parent.artifactId === item.lineage!.artifactId &&
parent.artifactVersion === item.lineage!.artifactVersion);
})());
const mediaMap = new Map(media.map((item) => [item.id, item]));
const threads = raw.threads.map((item) => normalizeThread(item, mediaMap, expected.userRef, scope));
const transcript = normalizeTranscript(raw.transcript, expected.userRef, scope);
return {media, threads: threads.filter((item): item is GenerationThread => item !== null), transcript,
uploadAuthorization: (raw as Record<string, unknown>).upload_authorization as MultimodalPage["uploadAuthorization"],
rejected: normalized.length - media.length + threads.filter((item) => item === null).length +
(raw.transcript && !transcript ? 1 : 0)};
}
function baseIntent(media: MediaView, scope: ArtifactScope) {
return {artifactId: media.artifactId, expectedVersion: media.artifactVersion, scope: {...scope}};
}
export function buildImageEdit(media: MediaView, scope: ArtifactScope,
instruction: string): ImageEditIntent {
const clean = safeText(instruction, "", 2_000);
if (media.kind !== "image" || !clean) throw new TypeError("A bounded image edit instruction is required");
return {...baseIntent(media, scope), mediaId: media.id, instruction: clean,
lineage: {artifactId: media.artifactId, version: media.artifactVersion}};
}
export function buildAnnotation(media: MediaView, scope: ArtifactScope,
note: string, page: number | null): AnnotationIntent {
const clean = safeText(note, "", 1_000);
if (media.kind !== "document" || !clean || !(page === null ||
(Number.isSafeInteger(page) && page > 0 && page <= 100_000))) {
throw new TypeError("A bounded document annotation is required");
}
return {...baseIntent(media, scope), mediaId: media.id, page, note: clean};
}
export function buildTranscriptCorrection(transcript: TranscriptView, correctedText: string,
identity: HuxIdentity, scope: ArtifactScope): TranscriptCorrectionIntent {
const clean = safeText(correctedText, "", 20_000);
if (!transcript.finalized || !clean) throw new TypeError("Only a finalized transcript can be corrected");
return {transcriptId: transcript.id, turnId: transcript.turnId,
expectedRevision: transcript.revision, correctedText: clean, identity: {...identity}, scope: {...scope}};
}
/** Camera and screen input always require a fresh Ask First decision. */
export function captureAuthorization(policy: PolicyView | null, approval: ApprovalView | null,
identity: HuxIdentity): "approval_required" | "deny" {
return actionAuthorization(policy, "network", approval, identity) === "deny" ? "deny" : "approval_required";
}
export function buildCaptureIntent(kind: CaptureKind, policy: PolicyView | null,
identity: HuxIdentity, scope: ArtifactScope): CaptureIntent {
if (!['camera', 'screen'].includes(kind) || captureAuthorization(policy, null, identity) === "deny") {
throw new TypeError("Capture is denied by the autonomy policy");
}
return {kind, autonomyCapability: "network", requiredDecision: "ask",
identity: {...identity}, scope: {...scope}};
}

View File

@ -0,0 +1,99 @@
/** Identity, binding, media, and display guards for HUX-07. */
import type { ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import type { FileCandidate, MediaKind, UploadValidation } from "./types.ts";
const ID = /^[a-z]{2,8}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/g;
const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi;
const MIME_KIND = new Map<string, MediaKind>([
["image/png", "image"], ["image/jpeg", "image"], ["image/webp", "image"],
["application/pdf", "document"], ["text/plain", "document"],
["text/markdown", "document"], ["audio/webm", "audio"], ["audio/wav", "audio"],
["audio/mpeg", "audio"], ["audio/ogg", "audio"],
]);
const MAX_BYTES: Readonly<Record<MediaKind, number>> = {
image: 20 * 1024 * 1024, document: 25 * 1024 * 1024, audio: 50 * 1024 * 1024,
};
export function isOpaqueId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) && (!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
}
export function safeText(value: unknown, fallback: string, limit = 500): string {
if (typeof value !== "string") return fallback;
const clean = value.replace(SECRET, "$1=[redacted]").replace(CONTROL, " ")
.replace(/\s+/g, " ").trim();
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const tenantRef = value.tenant_ref ?? value.tenantRef;
const userRef = value.user_ref ?? value.userRef;
if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) ||
typeof userRef !== "string" || !USER.test(userRef) ||
!["chat", "worker", "telegram", "voice", "api"].includes(String(value.surface))) return null;
return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]};
}
export function normalizeScope(raw: unknown): ArtifactScope | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
const projectId = value.project_id ?? value.projectId;
const conversationId = value.conversation_id ?? value.conversationId;
return isOpaqueId(projectId, "prj") && isOpaqueId(conversationId, "conv") ?
{projectId, conversationId} : null;
}
export function sameIdentity(left: HuxIdentity | null, right: HuxIdentity): boolean {
return Boolean(left && left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface);
}
export function sameScope(left: ArtifactScope | null, right: ArtifactScope): boolean {
return Boolean(left && left.projectId === right.projectId && left.conversationId === right.conversationId);
}
/** Preview URLs must be local object URLs. Inline data and remote resources are never accepted. */
export function safeBlobUrl(value: unknown): string | null {
return typeof value === "string" && /^blob:(?:https?:\/\/|null\/)[^\s]+$/.test(value) ? value : null;
}
/** Text previews render only as text nodes; controls that can spoof layout are rejected. */
export function safePreviewText(value: unknown, limit = 200_000): string | null {
return typeof value === "string" && value.length <= limit &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ? value : null;
}
export function classifyMime(value: unknown): MediaKind | null {
return typeof value === "string" ? MIME_KIND.get(value.toLowerCase()) || null : null;
}
/** Validate before reading bytes or handing a File to a transport adapter. */
export function validateUpload(file: FileCandidate, authorization: string): UploadValidation {
if (authorization !== "authorized") return {ok: false, reason: authorization === "requires_approval" ?
"Upload approval is required before selecting media." : "Media uploads are denied for this conversation."};
const kind = classifyMime(file?.type);
if (!kind) return {ok: false, reason: "This file type is not supported."};
if (!Number.isSafeInteger(file.size) || file.size < 1 || file.size > MAX_BYTES[kind]) {
return {ok: false, reason: `This ${kind} is empty or exceeds the ${MAX_BYTES[kind] / 1024 / 1024} MB limit.`};
}
const name = safeText(file.name, "", 180);
if (!name || /[/\\]/.test(name)) return {ok: false, reason: "The file name is invalid."};
return {ok: true, kind, file: {name, type: file.type.toLowerCase(), size: file.size}};
}
export function safeEndpointPart(value: unknown, prefix: string): string {
if (!isOpaqueId(value, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
return encodeURIComponent(value);
}

View File

@ -0,0 +1,147 @@
.hux-multimodal {
--mm-border: color-mix(in srgb, currentColor 16%, transparent);
display: grid;
gap: 1rem;
min-width: 0;
}
.hux-multimodal > header,
.hux-mm-capture,
.hux-mm-workspace,
.hux-mm-editor {
display: flex;
gap: .75rem;
}
.hux-multimodal > header {
align-items: start;
justify-content: space-between;
}
.hux-multimodal h2,
.hux-multimodal h3,
.hux-multimodal p {
margin-block: .2rem;
}
.hux-mm-kicker {
font-size: .72rem;
letter-spacing: .14em;
text-transform: uppercase;
}
.hux-mm-upload {
border: 1px dashed var(--mm-border);
border-radius: .8rem;
padding: 1.25rem;
text-align: center;
}
.hux-mm-workspace {
align-items: stretch;
min-height: 24rem;
}
.hux-mm-workspace > nav,
.hux-mm-workspace > aside {
flex: 0 0 min(18rem, 25%);
}
.hux-mm-workspace > main {
border-inline: 1px solid var(--mm-border);
flex: 1;
min-width: 0;
padding-inline: 1rem;
}
.hux-mm-workspace ul,
.hux-mm-workspace ol {
list-style: none;
margin: 0;
padding: 0;
}
.hux-mm-workspace nav button {
align-items: center;
display: flex;
gap: .5rem;
overflow-wrap: anywhere;
width: 100%;
}
.hux-mm-workspace button[aria-current="page"] {
outline: 2px solid currentColor;
outline-offset: -2px;
}
.hux-mm-image img {
display: block;
height: auto;
max-height: 28rem;
max-width: 100%;
object-fit: contain;
}
.hux-mm-safe-preview {
align-items: center;
border: 1px solid var(--mm-border);
border-radius: .6rem;
display: grid;
gap: .35rem;
justify-items: center;
min-height: 12rem;
padding: 1rem;
text-align: center;
}
.hux-mm-editor,
.hux-mm-transcript {
flex-direction: column;
margin-top: 1rem;
}
.hux-mm-editor textarea,
.hux-mm-transcript textarea {
min-height: 6rem;
resize: vertical;
}
.hux-mm-error,
.hux-mm-warning {
border-inline-start: .25rem solid currentColor;
padding: .65rem .85rem;
}
.hux-mm-workspace aside li {
border-bottom: 1px solid var(--mm-border);
display: grid;
padding-block: .5rem;
}
@media (max-width: 62rem) {
.hux-mm-workspace,
.hux-multimodal > header {
flex-direction: column;
}
.hux-mm-workspace > nav,
.hux-mm-workspace > aside {
flex-basis: auto;
}
.hux-mm-workspace > main {
border-block: 1px solid var(--mm-border);
border-inline: 0;
padding-block: 1rem;
padding-inline: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-multimodal *,
.hux-multimodal *::before,
.hux-multimodal *::after {
scroll-behavior: auto !important;
transition-duration: .01ms !important;
}
}

View File

@ -0,0 +1,140 @@
/** Frontend-only contracts for default-off HUX-07 multimodal chat. */
import type { ArtifactMutationIntent, ArtifactScope, HuxIdentity } from "../artifacts/types.ts";
import type { PolicyView } from "../autonomy/types.ts";
export type MediaKind = "image" | "document" | "audio";
export type MediaAuthorization = "authorized" | "requires_approval" | "denied";
export type CaptureKind = "camera" | "screen";
export interface RawMediaEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
binding?: unknown;
media?: unknown;
threads?: unknown;
transcript?: unknown;
upload_authorization?: unknown;
}
export interface MediaView {
id: string;
owner: string;
artifactId: string;
artifactVersion: number;
kind: MediaKind;
fileName: string;
mime: string;
bytes: number;
previewUrl: string | null;
previewText: string | null;
altText: string;
annotations: ReadonlyArray<{id: string; page: number | null; note: string}>;
lineage: {mediaId: string; artifactId: string; artifactVersion: number} | null;
}
export interface GenerationVariant {
id: string;
mediaId: string;
artifactId: string;
artifactVersion: number;
parentVariantId: string | null;
createdAt: string;
}
export interface GenerationThread {
id: string;
prompt: string;
variants: GenerationVariant[];
}
export interface TranscriptView {
id: string;
turnId: string;
revision: number;
language: string;
text: string;
finalized: boolean;
}
export interface MultimodalPage {
media: MediaView[];
threads: GenerationThread[];
transcript: TranscriptView | null;
uploadAuthorization: MediaAuthorization;
rejected: number;
}
export interface FileCandidate {
name: string;
type: string;
size: number;
}
export type UploadValidation =
| {ok: true; kind: MediaKind; file: FileCandidate}
| {ok: false; reason: string};
export interface ImageEditIntent extends ArtifactMutationIntent {
mediaId: string;
instruction: string;
lineage: {artifactId: string; version: number};
}
export interface AnnotationIntent extends ArtifactMutationIntent {
mediaId: string;
page: number | null;
note: string;
}
export interface TranscriptCorrectionIntent {
transcriptId: string;
turnId: string;
expectedRevision: number;
correctedText: string;
identity: HuxIdentity;
scope: ArtifactScope;
}
export interface CaptureIntent {
kind: CaptureKind;
autonomyCapability: "network";
requiredDecision: "ask";
identity: HuxIdentity;
scope: ArtifactScope;
}
export interface MultimodalProps {
flags?: Iterable<string>;
identity: HuxIdentity;
scope: ArtifactScope;
page: RawMediaEnvelope;
autonomyPolicy: PolicyView | null;
busy?: boolean;
error?: string | null;
onUpload?: (files: readonly FileCandidate[]) => void;
onRequestAuthorization?: (action: "upload") => void;
onImageEdit?: (intent: ImageEditIntent) => void;
onAnnotate?: (intent: AnnotationIntent) => void;
onCorrectTranscript?: (intent: TranscriptCorrectionIntent) => void;
onCapture?: (intent: CaptureIntent) => void;
}
export interface MultimodalEndpoint {
method: "GET" | "POST";
path: string;
responseSchema: string;
requiredBody?: readonly string[];
}
export interface MultimodalEndpointContract {
page: MultimodalEndpoint;
beginUpload: MultimodalEndpoint;
finalizeUpload(uploadId: string): MultimodalEndpoint;
editImage(mediaId: string): MultimodalEndpoint;
annotate(mediaId: string): MultimodalEndpoint;
correctTranscript(transcriptId: string): MultimodalEndpoint;
requestCapture(kind: CaptureKind): MultimodalEndpoint;
createVariant(threadId: string): MultimodalEndpoint;
}

View File

@ -0,0 +1,117 @@
/** Restrained, accessible HUX-09 suggestion card. No action runs implicitly. */
import {useEffect, useState} from "react";
import {buildActionIntent, onboardingEnabled} from "./model.ts";
import type {
OnboardingActionIntent,
OnboardingClient,
SuggestionDecision,
SuggestionEvaluation,
} from "./types.ts";
interface OnboardingSuggestionsProps {
flags?: Iterable<string>;
client: OnboardingClient;
evaluation: SuggestionEvaluation | null;
disabled?: boolean;
onAction(intent: OnboardingActionIntent): void | Promise<void>;
onResolved?(decision: SuggestionDecision): void;
}
const CONTEXT_LABELS = {
first_session: "Shown because this is a new workspace session.",
empty_project: "Shown because this project has no conversations yet.",
after_artifact: "Shown because you just created an artifact.",
after_research: "Shown because a research task just finished.",
after_approval: "Shown because an approved task just finished.",
idle: "Shown after this conversation became idle.",
} as const;
function actionLabel(evaluation: SuggestionEvaluation): string | null {
const {action} = evaluation.suggestion;
if (action.type === "open_mode") return "Choose a mode";
if (action.type === "create_project") return "Add to a project";
if (action.type === "open_memory") return "Review memory";
if (action.type === "open_artifacts") return "View artifacts";
if (action.type === "start_workflow") {
return action.payload?.intent === "schedule_weekly" ? "Run weekly" : "Save workflow";
}
return null;
}
/** Render nothing until the four HUX dependencies and an eligible claim exist. */
export function OnboardingSuggestions(props: OnboardingSuggestionsProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [resolved, setResolved] = useState(false);
const enabled = onboardingEnabled(props.flags) && props.client?.enabled();
const evaluation = props.evaluation;
useEffect(() => {
setBusy(false);
setError("");
setResolved(false);
}, [evaluation?.claimId]);
if (!enabled || !evaluation || evaluation.privacy.noStore || resolved) return null;
if (evaluation.suggestion.action.type === "open_memory" &&
!evaluation.privacy.memorySuggestionsAllowed) return null;
const label = actionLabel(evaluation);
async function resolve(decision: SuggestionDecision): Promise<void> {
if (busy || props.disabled) return;
setBusy(true);
setError("");
try {
if (decision === "acted") {
const intent = buildActionIntent(evaluation);
if (!intent) throw new Error("This suggestion has no safe action");
await props.onAction(intent);
}
await props.client.decide(evaluation, decision);
setResolved(true);
props.onResolved?.(decision);
} catch (_) {
setError("That choice could not be saved. Nothing else was changed.");
} finally {
setBusy(false);
}
}
return (
<aside className="hux-onboarding" aria-labelledby={`hux-onboarding-${evaluation.suggestion.id}`}>
<div className="hux-onboarding-copy">
<span className="hux-onboarding-kicker">Optional next step</span>
<h2 id={`hux-onboarding-${evaluation.suggestion.id}`}>
{evaluation.suggestion.title}
</h2>
<p>{evaluation.suggestion.body}</p>
<details className="hux-onboarding-why">
<summary>Why am I seeing this?</summary>
<p>{CONTEXT_LABELS[evaluation.suggestion.trigger.context]}</p>
</details>
</div>
<div className="hux-onboarding-controls" aria-busy={busy}>
{label && (
<button type="button" className="hux-onboarding-primary"
disabled={busy || props.disabled} onClick={() => void resolve("acted")}>
{label}
</button>
)}
<button type="button" disabled={busy || props.disabled}
onClick={() => void resolve("dismiss")}>
Not now
</button>
<button type="button" className="hux-onboarding-never"
disabled={busy || props.disabled} onClick={() => void resolve("never_again")}>
Don&apos;t suggest this again
</button>
</div>
<div className="hux-onboarding-status" role="status" aria-live="polite">
{busy ? "Saving your choice…" : ""}
</div>
{error && <p className="hux-onboarding-error" role="alert">{error}</p>}
</aside>
);
}

View File

@ -0,0 +1,145 @@
/** Same-origin, session-bound transport for contextual HUX-09 suggestions. */
import {ACCEPT, API_VERSION, DECISION_SCHEMA, EVALUATION_SCHEMA} from "./contracts.ts";
import {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
ONBOARDING_FLAG,
PRIVACY_FLAG,
PROJECTS_FLAG,
normalizeDecisionState,
normalizeEvaluation,
} from "./model.ts";
import {
asRecord,
exactKeys,
isId,
normalizeIdentity,
normalizeScope,
sameIdentity,
scopedSuggestionPath,
} from "./security.ts";
import type {
FetchResponse,
HuxFoundationClient,
OnboardingScope,
RawSuggestionState,
SuggestionDecision,
SuggestionEvaluation,
SuggestionState,
} from "./types.ts";
interface ClientOptions {
client: HuxFoundationClient;
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
}
export class OnboardingContractError extends Error {
constructor(message: string) {
super(message);
this.name = "OnboardingContractError";
}
}
function hasCoreFlags(client: HuxFoundationClient): boolean {
return client.apiVersion === API_VERSION && [
FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG,
].every((flag) => client.enabled(flag));
}
export function createOnboardingClient(options: ClientOptions) {
const client = options?.client;
const fetcher = options?.fetcher ||
(typeof fetch === "function" ? fetch.bind(globalThis) : null);
if (!client || !fetcher) throw new TypeError("Onboarding requires HUX and fetch clients");
const identity = normalizeIdentity({
tenant_ref: client.identity?.tenantRef,
user_ref: client.identity?.userRef,
surface: client.identity?.surface,
});
if (!identity) throw new TypeError("Onboarding requires a scoped HUX identity");
const seenClaims = new Set<string>();
const activeClaims = new Map<string, SuggestionEvaluation>();
async function request(path: string, body: Record<string, unknown>): Promise<unknown> {
if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled");
const response = await fetcher(client.endpoint(path), {
method: "POST", cache: "no-store", credentials: "same-origin",
headers: {Accept: ACCEPT, "Content-Type": "application/json"},
body: JSON.stringify(body),
});
if (response.status === 404 || response.status === 204) return null;
if (!response.ok) throw new OnboardingContractError(
response.status === 409 ? "This suggestion is no longer current" :
`Onboarding request failed (${response.status})`,
);
return response.json();
}
async function evaluate(scope: OnboardingScope): Promise<SuggestionEvaluation | null> {
if (!hasCoreFlags(client)) throw new OnboardingContractError("Onboarding is not enabled");
const safeScope = normalizeScope(scope, identity);
if (!safeScope) throw new TypeError("Onboarding trigger is outside this session");
const raw = await request("/onboarding/evaluate", {
schema: "hux.suggestion_evaluation_request.v1",
session_id: safeScope.sessionId,
conversation_id: safeScope.conversationId,
trigger: safeScope.trigger,
});
if (raw === null) return null;
const result = normalizeEvaluation(raw, identity, safeScope, client.enabled(PRIVACY_FLAG));
if (!result || seenClaims.has(result.claimId)) return null;
seenClaims.add(result.claimId);
activeClaims.set(result.claimId, result);
return result;
}
function normalizeDecision(
raw: unknown,
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): SuggestionState | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "api_version", "identity", "session_id", "conversation_id",
"claim_id", "suggestion_id", "state",
]) || value.schema !== DECISION_SCHEMA || value.api_version !== API_VERSION ||
value.session_id !== evaluation.scope.sessionId ||
value.conversation_id !== evaluation.scope.conversationId ||
value.claim_id !== evaluation.claimId ||
value.suggestion_id !== evaluation.suggestion.id ||
!isId(value.claim_id, "clm") || !isId(value.suggestion_id, "sug")) return null;
const actualIdentity = normalizeIdentity(value.identity);
if (!actualIdentity || !sameIdentity(actualIdentity, identity)) return null;
return normalizeDecisionState(value.state as RawSuggestionState, evaluation, decision);
}
async function decide(
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): Promise<SuggestionState> {
if (!["dismiss", "never_again", "acted"].includes(decision) ||
activeClaims.get(evaluation?.claimId) !== evaluation) {
throw new TypeError("Suggestion decision is not bound to this session");
}
const path = scopedSuggestionPath("/onboarding/suggestions", evaluation.suggestion.id);
const raw = await request(path, {
schema: "hux.suggestion_decision_request.v1",
session_id: evaluation.scope.sessionId,
conversation_id: evaluation.scope.conversationId,
claim_id: evaluation.claimId,
suggestion_id: evaluation.suggestion.id,
expected_shows: evaluation.state.shows,
decision,
});
const state = normalizeDecision(raw, evaluation, decision);
if (!state) throw new OnboardingContractError("Suggestion decision crossed its scope");
activeClaims.delete(evaluation.claimId);
return state;
}
return Object.freeze({
enabled: () => hasCoreFlags(client), evaluate, decide,
schemas: Object.freeze({evaluation: EVALUATION_SCHEMA, decision: DECISION_SCHEMA}),
});
}

View File

@ -0,0 +1,32 @@
/** Same-origin backend contracts required by the HUX-09 frontend. */
export const API_VERSION = "hux.v1";
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
export const EVALUATION_SCHEMA = "hux.suggestion_evaluation.v1";
export const DECISION_SCHEMA = "hux.suggestion_decision.v1";
/**
* POST /hux/v1/onboarding/evaluate
* POST /hux/v1/onboarding/suggestions/{sug_id}/decisions
*
* Evaluate accepts only schema, session_id, conversation_id and the exact
* trigger. Identity MUST NOT be accepted from browser storage, query
* parameters, or request bodies; derive it from the authenticated session.
* The server verifies that both opaque ids belong to that identity and surface.
*
* Before returning a candidate, evaluation MUST atomically apply never_again,
* max_shows and cooldown_seconds from suggestion.schema.json, increment shows,
* and bind a single-use claim_id to identity + session + conversation + trigger.
* If no_store is active, the server MUST NOT write suppression state and
* returns suggestion/state/claim_id as null. Memory suggestions require the
* hux.privacy capability and memory_suggestions_allowed=true.
*
* Decisions accept only schema, session_id, conversation_id, claim_id,
* suggestion_id, expected_shows and decision. The server rechecks the claim
* binding and returns 409 for stale/replayed claims. A decision never performs
* the suggested action; it only persists dismiss, never-again, or acted state.
*/
export const BACKEND_ENDPOINTS = Object.freeze({
evaluate: "/onboarding/evaluate",
decision: "/onboarding/suggestions/{sug_id}/decisions",
});

View File

@ -0,0 +1,20 @@
/** Public integration surface for the inert HUX-09 onboarding module. */
export {createOnboardingClient, OnboardingContractError} from "./client.ts";
export {BACKEND_ENDPOINTS} from "./contracts.ts";
export {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
ONBOARDING_FLAG,
PRIVACY_FLAG,
PROJECTS_FLAG,
REQUIRED_FLAGS,
buildActionIntent,
normalizeDecisionState,
normalizeEvaluation,
normalizeState,
normalizeSuggestion,
onboardingEnabled,
} from "./model.ts";
export {OnboardingSuggestions} from "./OnboardingSuggestions.tsx";
export type * from "./types.ts";

View File

@ -0,0 +1,246 @@
/** Fail-closed suggestion.schema.json adapters for HUX-09. */
import {
asRecord,
exactKeys,
isId,
isSafeText,
isUtc,
normalizeIdentity,
normalizeTrigger,
sameIdentity,
sameTrigger,
} from "./security.ts";
import type {
OnboardingActionIntent,
OnboardingScope,
OnboardingSuggestion,
RawSuggestion,
RawSuggestionState,
ScopedIdentity,
SuggestionActionType,
SuggestionDecision,
SuggestionEvaluation,
SuggestionKind,
SuggestionState,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
export const ONBOARDING_FLAG = "hux.onboarding";
export const PRIVACY_FLAG = "hux.privacy";
export const REQUIRED_FLAGS = Object.freeze([
FOUNDATION_FLAG,
PROJECTS_FLAG,
FRIENDLY_MODES_FLAG,
ONBOARDING_FLAG,
]);
const KINDS = new Set<SuggestionKind>(["workflow", "project", "feature", "tip"]);
const ACTIONS = new Set<SuggestionActionType>([
"open_mode", "create_project", "open_memory", "open_artifacts",
"start_workflow", "none",
]);
const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]);
const INTENTS = new Set(["schedule_weekly", "save_reusable"]);
const KIND_ACTIONS: Readonly<Record<SuggestionKind, ReadonlySet<SuggestionActionType>>> = {
workflow: new Set(["start_workflow"]),
project: new Set(["create_project"]),
feature: new Set(["open_mode", "open_memory", "open_artifacts"]),
tip: new Set(["none"]),
};
function finiteInteger(value: unknown, min: number, max: number): value is number {
return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
}
function normalizePayload(
type: SuggestionActionType,
raw: unknown,
): Readonly<Record<string, string>> | null | undefined {
const value = raw === undefined ? {} : asRecord(raw);
if (!value) return null;
if (type === "start_workflow") {
return exactKeys(value, ["intent"]) && INTENTS.has(value.intent as string) ?
{intent: value.intent as string} : null;
}
if (type === "create_project") {
if (!exactKeys(value, [], ["prefill_title"])) return null;
return value.prefill_title === undefined ? {} :
isSafeText(value.prefill_title, 1, 120) ? {prefill_title: value.prefill_title} : null;
}
if (type === "open_mode") {
if (!exactKeys(value, [], ["mode"])) return null;
return value.mode === undefined ? {} : MODES.has(value.mode as string) ?
{mode: value.mode as string} : null;
}
if (type === "open_memory") {
return exactKeys(value, [], ["view"]) &&
(value.view === undefined || value.view === "suggestions") ?
(value.view ? {view: "suggestions"} : {}) : null;
}
if (type === "open_artifacts") {
return exactKeys(value, [], ["view"]) &&
(value.view === undefined || value.view === "current") ?
(value.view ? {view: "current"} : {}) : null;
}
return exactKeys(value, []) ? undefined : null;
}
export function onboardingEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return REQUIRED_FLAGS.every((flag) => enabled.has(flag));
}
export function normalizeSuggestion(
raw: RawSuggestion,
expectedTrigger: OnboardingScope["trigger"],
): OnboardingSuggestion | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "id", "kind", "trigger", "title", "body", "priority", "suppression",
], ["action"]) || value.schema !== "hux.suggestion.v1" || !isId(value.id, "sug") ||
!KINDS.has(value.kind as SuggestionKind) || !isSafeText(value.title, 1, 80) ||
!isSafeText(value.body, 1, 280) || !finiteInteger(value.priority, 0, 100)) return null;
const trigger = normalizeTrigger(value.trigger);
const suppression = asRecord(value.suppression);
if (!trigger || !sameTrigger(trigger, expectedTrigger) || !suppression ||
!exactKeys(suppression, [
"dismissable", "max_shows", "cooldown_seconds", "never_again_supported",
]) || suppression.dismissable !== true || suppression.never_again_supported !== true ||
!finiteInteger(suppression.max_shows, 1, 5) ||
!finiteInteger(suppression.cooldown_seconds, 3600, Number.MAX_SAFE_INTEGER)) return null;
const action = value.action === undefined ? {type: "none"} : asRecord(value.action);
if (!action || !exactKeys(action, ["type"], ["payload"]) ||
!ACTIONS.has(action.type as SuggestionActionType)) return null;
const type = action.type as SuggestionActionType;
const payload = normalizePayload(type, action.payload);
const kind = value.kind as SuggestionKind;
if (payload === null || !KIND_ACTIONS[kind].has(type)) return null;
return {
schema: "hux.suggestion.v1", id: value.id as string, kind, trigger,
title: value.title as string, body: value.body as string,
action: {type, ...(payload === undefined ? {} : {payload})},
priority: value.priority as number,
suppression: {
dismissable: true, maxShows: suppression.max_shows as number,
cooldownSeconds: suppression.cooldown_seconds as number,
neverAgainSupported: true,
},
};
}
export function normalizeState(
raw: RawSuggestionState,
suggestion: OnboardingSuggestion,
owner: string,
): SuggestionState | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again",
], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" ||
value.owner !== owner || value.suggestion_id !== suggestion.id ||
!finiteInteger(value.shows, 1, suggestion.suppression.maxShows) ||
!isUtc(value.last_shown_at) || value.never_again !== false ||
(value.dismissed_at !== undefined && !isUtc(value.dismissed_at)) ||
(value.acted_at !== undefined && !isUtc(value.acted_at))) return null;
return {
schema: "hux.suggestion_state.v1", owner, suggestionId: suggestion.id,
shows: value.shows as number, lastShownAt: value.last_shown_at as string,
...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}),
...(value.acted_at ? {actedAt: value.acted_at as string} : {}),
neverAgain: false,
};
}
export function normalizeDecisionState(
raw: RawSuggestionState,
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): SuggestionState | null {
const value = asRecord(raw);
const suggestion = evaluation.suggestion;
if (!value || !exactKeys(value, [
"schema", "owner", "suggestion_id", "shows", "last_shown_at", "never_again",
], ["dismissed_at", "acted_at"]) || value.schema !== "hux.suggestion_state.v1" ||
value.owner !== evaluation.identity.userRef || value.suggestion_id !== suggestion.id ||
value.shows !== evaluation.state.shows || !isUtc(value.last_shown_at) ||
value.last_shown_at !== evaluation.state.lastShownAt ||
typeof value.never_again !== "boolean") return null;
const dismissed = value.dismissed_at === undefined || isUtc(value.dismissed_at);
const acted = value.acted_at === undefined || isUtc(value.acted_at);
const outcomeMatches = decision === "acted" ?
Boolean(value.acted_at) && value.never_again === false :
Boolean(value.dismissed_at) &&
value.never_again === (decision === "never_again");
if (!dismissed || !acted || !outcomeMatches) return null;
return {
schema: "hux.suggestion_state.v1", owner: evaluation.identity.userRef,
suggestionId: suggestion.id, shows: value.shows as number,
lastShownAt: value.last_shown_at as string,
...(value.dismissed_at ? {dismissedAt: value.dismissed_at as string} : {}),
...(value.acted_at ? {actedAt: value.acted_at as string} : {}),
neverAgain: value.never_again,
};
}
export function normalizeEvaluation(
raw: unknown,
expectedIdentity: ScopedIdentity,
expectedScope: OnboardingScope,
privacyCapability: boolean,
): SuggestionEvaluation | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "api_version", "identity", "session_id", "conversation_id", "trigger",
"privacy", "claim_id", "issued_at", "suggestion", "state",
]) || value.schema !== "hux.suggestion_evaluation.v1" || value.api_version !== "hux.v1")
return null;
const identity = normalizeIdentity(value.identity);
const trigger = normalizeTrigger(value.trigger);
const privacy = asRecord(value.privacy);
if (!identity || !sameIdentity(identity, expectedIdentity) ||
value.session_id !== expectedScope.sessionId ||
value.conversation_id !== expectedScope.conversationId || !trigger ||
!sameTrigger(trigger, expectedScope.trigger) || !privacy ||
!exactKeys(privacy, ["schema", "no_store", "memory_suggestions_allowed"]) ||
privacy.schema !== "hux.onboarding_privacy.v1" ||
typeof privacy.no_store !== "boolean" ||
typeof privacy.memory_suggestions_allowed !== "boolean") return null;
if (privacy.no_store || value.suggestion === null || value.state === null ||
value.claim_id === null) return null;
if (!isId(value.claim_id, "clm") || !isUtc(value.issued_at)) return null;
const suggestion = normalizeSuggestion(value.suggestion as RawSuggestion, expectedScope.trigger);
if (!suggestion || (suggestion.action.type === "open_memory" &&
(!privacyCapability || privacy.memory_suggestions_allowed !== true))) return null;
const state = normalizeState(value.state as RawSuggestionState, suggestion, identity.userRef);
if (!state) return null;
return {
schema: "hux.suggestion_evaluation.v1", identity,
scope: expectedScope, claimId: value.claim_id, issuedAt: value.issued_at,
privacy: {
schema: "hux.onboarding_privacy.v1", noStore: false,
memorySuggestionsAllowed: privacy.memory_suggestions_allowed,
},
suggestion, state,
};
}
export function buildActionIntent(
evaluation: SuggestionEvaluation,
): OnboardingActionIntent | null {
const {action} = evaluation.suggestion;
const payload = action.payload || {};
if (action.type === "open_mode") return {type: action.type, ...(payload.mode ? {mode: payload.mode} : {})};
if (action.type === "create_project") return {type: action.type,
conversationId: evaluation.scope.conversationId,
...(payload.prefill_title ? {prefillTitle: payload.prefill_title} : {})};
if (action.type === "open_memory") return {type: action.type, view: "suggestions"};
if (action.type === "open_artifacts") return {type: action.type, view: "current"};
if (action.type === "start_workflow") return {type: action.type,
intent: payload.intent as "schedule_weekly" | "save_reusable"};
return null;
}

View File

@ -0,0 +1,98 @@
/** Identity, scope, trigger and time guards for HUX-09. */
import type {
HuxSurface,
OnboardingScope,
RawIdentity,
ScopedIdentity,
SuggestionContext,
SuggestionTrigger,
} from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/;
const SURFACES = new Set<HuxSurface>(["chat", "worker", "telegram", "voice", "api"]);
const CONTEXTS = new Set<SuggestionContext>([
"first_session", "empty_project", "after_artifact", "after_research",
"after_approval", "idle",
]);
export function exactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const allowed = new Set([...required, ...optional]);
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key));
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ?
value as Record<string, unknown> : null;
}
export function isId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) &&
!Number.isNaN(Date.parse(value));
}
export function isSafeText(value: unknown, min: number, max: number): value is string {
return typeof value === "string" && value.length >= min && value.length <= max &&
!CONTROL.test(value);
}
export function normalizeIdentity(raw: unknown): ScopedIdentity | null {
const value = raw as RawIdentity | null;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
if (!exactKeys(value as Record<string, unknown>, ["tenant_ref", "user_ref", "surface"]))
return null;
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) ||
typeof value.surface !== "string" || !SURFACES.has(value.surface as HuxSurface))
return null;
return {tenantRef: value.tenant_ref, userRef: value.user_ref,
surface: value.surface as HuxSurface};
}
export function sameIdentity(left: ScopedIdentity, right: ScopedIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function normalizeTrigger(raw: unknown): SuggestionTrigger | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, ["surface", "context"]) ||
!SURFACES.has(value.surface as HuxSurface) ||
!CONTEXTS.has(value.context as SuggestionContext)) return null;
return {surface: value.surface as HuxSurface, context: value.context as SuggestionContext};
}
export function sameTrigger(left: SuggestionTrigger, right: SuggestionTrigger): boolean {
return left.surface === right.surface && left.context === right.context;
}
export function normalizeScope(raw: OnboardingScope, identity: ScopedIdentity): OnboardingScope | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, ["sessionId", "conversationId", "trigger"]) ||
!isId(value.sessionId, "ses") || !isId(value.conversationId, "conv")) return null;
const trigger = normalizeTrigger(value.trigger);
if (!trigger || trigger.surface !== identity.surface) return null;
return {sessionId: value.sessionId, conversationId: value.conversationId, trigger};
}
export function scopedSuggestionPath(base: string, suggestionId: string): string {
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) ||
base.includes("..") || !isId(suggestionId, "sug")) {
throw new TypeError("Onboarding path is not safely scoped");
}
return `${base.replace(/\/$/, "")}/${encodeURIComponent(suggestionId)}/decisions`;
}

View File

@ -0,0 +1,124 @@
/* Component-scoped HUX-09 presentation; importing it cannot recolor the page. */
.hux-onboarding {
background: color-mix(in srgb, #22d3ee 4%, transparent);
border: 1px solid color-mix(in srgb, #22d3ee 22%, currentColor 8%);
border-radius: 0.8rem;
color: inherit;
container-type: inline-size;
display: grid;
gap: 0.7rem 1rem;
grid-template-columns: minmax(0, 1fr) auto;
padding: 0.8rem 0.9rem;
}
.hux-onboarding-copy {
min-width: 0;
}
.hux-onboarding-kicker {
color: color-mix(in srgb, currentColor 60%, #22d3ee);
display: block;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.055em;
text-transform: uppercase;
}
.hux-onboarding h2 {
font-size: 0.8125rem;
font-weight: 680;
line-height: 1.35;
margin: 0.15rem 0 0;
}
.hux-onboarding-copy > p,
.hux-onboarding-why p {
color: color-mix(in srgb, currentColor 68%, transparent);
font-size: 0.6875rem;
line-height: 1.45;
margin: 0.2rem 0 0;
}
.hux-onboarding-why {
font-size: 0.625rem;
margin-top: 0.45rem;
}
.hux-onboarding-why summary {
color: color-mix(in srgb, currentColor 58%, transparent);
cursor: pointer;
width: fit-content;
}
.hux-onboarding-controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: flex-end;
}
.hux-onboarding button {
background: transparent;
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
border-radius: 0.55rem;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.6875rem;
min-height: 2rem;
padding: 0.35rem 0.6rem;
}
.hux-onboarding .hux-onboarding-primary {
background: color-mix(in srgb, #22d3ee 12%, transparent);
border-color: color-mix(in srgb, #22d3ee 48%, currentColor);
font-weight: 650;
}
.hux-onboarding .hux-onboarding-never {
border-color: transparent;
color: color-mix(in srgb, currentColor 58%, transparent);
}
.hux-onboarding button:focus-visible,
.hux-onboarding summary:focus-visible {
outline: 2px solid #22d3ee;
outline-offset: 2px;
}
.hux-onboarding button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.hux-onboarding-status,
.hux-onboarding-error {
font-size: 0.625rem;
grid-column: 1 / -1;
margin: 0;
}
.hux-onboarding-status:empty {
display: none;
}
.hux-onboarding-error {
color: color-mix(in srgb, currentColor 82%, #fb7185);
}
@container (max-width: 38rem) {
.hux-onboarding {
grid-template-columns: 1fr;
}
.hux-onboarding-controls {
justify-content: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-onboarding * {
scroll-behavior: auto;
}
}

View File

@ -0,0 +1,142 @@
/** Scoped types for the default-off HUX-09 contextual onboarding surface. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type SuggestionContext =
| "first_session"
| "empty_project"
| "after_artifact"
| "after_research"
| "after_approval"
| "idle";
export type SuggestionKind = "workflow" | "project" | "feature" | "tip";
export type SuggestionActionType =
| "open_mode"
| "create_project"
| "open_memory"
| "open_artifacts"
| "start_workflow"
| "none";
export type SuggestionDecision = "dismiss" | "never_again" | "acted";
export interface ScopedIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface RawIdentity {
tenant_ref?: unknown;
user_ref?: unknown;
surface?: unknown;
}
export interface SuggestionTrigger {
surface: HuxSurface;
context: SuggestionContext;
}
export interface OnboardingScope {
sessionId: string;
conversationId: string;
trigger: SuggestionTrigger;
}
export interface RawSuggestion {
schema?: unknown;
id?: unknown;
kind?: unknown;
trigger?: unknown;
title?: unknown;
body?: unknown;
action?: unknown;
priority?: unknown;
suppression?: unknown;
}
export interface OnboardingSuggestion {
schema: "hux.suggestion.v1";
id: string;
kind: SuggestionKind;
trigger: SuggestionTrigger;
title: string;
body: string;
action: {
type: SuggestionActionType;
payload?: Readonly<Record<string, string>>;
};
priority: number;
suppression: {
dismissable: true;
maxShows: number;
cooldownSeconds: number;
neverAgainSupported: true;
};
}
export interface RawSuggestionState {
schema?: unknown;
owner?: unknown;
suggestion_id?: unknown;
shows?: unknown;
last_shown_at?: unknown;
dismissed_at?: unknown;
acted_at?: unknown;
never_again?: unknown;
}
export interface SuggestionState {
schema: "hux.suggestion_state.v1";
owner: string;
suggestionId: string;
shows: number;
lastShownAt: string;
dismissedAt?: string;
actedAt?: string;
neverAgain: boolean;
}
export interface PrivacySignal {
schema: "hux.onboarding_privacy.v1";
noStore: boolean;
memorySuggestionsAllowed: boolean;
}
export interface SuggestionEvaluation {
schema: "hux.suggestion_evaluation.v1";
identity: ScopedIdentity;
scope: OnboardingScope;
claimId: string;
issuedAt: string;
privacy: PrivacySignal;
suggestion: OnboardingSuggestion;
state: SuggestionState;
}
export type OnboardingActionIntent =
| {type: "open_mode"; mode?: string}
| {type: "create_project"; conversationId: string; prefillTitle?: string}
| {type: "open_memory"; view: "suggestions"}
| {type: "open_artifacts"; view: "current"}
| {type: "start_workflow"; intent: "schedule_weekly" | "save_reusable"};
export interface HuxFoundationClient {
apiVersion: string;
identity: ScopedIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
export interface FetchResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
export interface OnboardingClient {
enabled(): boolean;
evaluate(scope: OnboardingScope): Promise<SuggestionEvaluation | null>;
decide(
evaluation: SuggestionEvaluation,
decision: SuggestionDecision,
): Promise<SuggestionState>;
}

View File

@ -0,0 +1,252 @@
/** Accessible, default-off Projects and Conversations workspace for HUX-03. */
import { Folder, GitBranch, Paperclip, Pin, Search } from "lucide-react";
import { useMemo, useState } from "react";
import type { FormEvent } from "react";
import { attachmentViews, branchLineage, projectsEnabled } from "./model.ts";
import type {
ArtifactSummary,
Conversation,
Project,
} from "./types.ts";
interface OrganizationWorkspaceProps {
flags?: Iterable<string>;
projects: readonly Project[];
conversations: readonly Conversation[];
artifacts?: readonly ArtifactSummary[];
searchResults?: readonly Conversation[];
nextSearchCursor?: string | null;
busy?: boolean;
error?: string | null;
onSearch?: (query: string, cursor: string | null) => void;
onRenameProject?: (project: Project, name: string) => void;
onRenameConversation?: (conversation: Conversation, title: string) => void;
onUpdateProjectTags?: (project: Project, tags: string[]) => void;
onUpdateConversationTags?: (conversation: Conversation, tags: string[]) => void;
onMoveConversation?: (conversation: Conversation, projectId: string | null) => void;
onToggleProjectPin?: (project: Project) => void;
onToggleConversationPin?: (conversation: Conversation) => void;
}
function ordered<T extends {pinned: boolean; updatedAt: string}>(items: readonly T[]): T[] {
return [...items].sort((left, right) => Number(right.pinned) - Number(left.pinned) ||
right.updatedAt.localeCompare(left.updatedAt));
}
function Tags({values}: {values: readonly string[]}) {
if (!values.length) return null;
return <ul className="hux-org-tags" aria-label="Tags">
{values.map((tag) => <li key={tag}>{tag}</li>)}
</ul>;
}
function EditableTitle({label, value, maxLength, onSave}: {
label: string;
value: string;
maxLength: number;
onSave?: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (!onSave) return <span>{value}</span>;
if (!editing) return <button className="hux-org-title-button" type="button"
onClick={() => { setDraft(value); setEditing(true); }} aria-label={`${label}: ${value}. Rename`}>
Rename
</button>;
return <form className="hux-org-rename" onSubmit={(event) => {
event.preventDefault();
const next = draft.trim();
if (next && next !== value) onSave(next);
setEditing(false);
}}>
<label><span className="sr-only">{label}</span>
<input value={draft} maxLength={maxLength} autoFocus onChange={(event) => setDraft(event.target.value)} />
</label>
<button type="submit">Save</button>
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
</form>;
}
function EditableTags({label, values, onSave}: {
label: string;
values: readonly string[];
onSave?: (values: string[]) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(values.join(", "));
const [invalid, setInvalid] = useState(false);
if (!onSave) return <Tags values={values} />;
if (!editing) return <div><Tags values={values} /><button type="button"
onClick={() => { setDraft(values.join(", ")); setInvalid(false); setEditing(true); }}>
Edit tags
</button></div>;
return <form className="hux-org-rename" onSubmit={(event) => {
event.preventDefault();
const tags = draft.split(",").map((tag) => tag.trim()).filter(Boolean);
const valid = tags.length <= 32 && new Set(tags).size === tags.length &&
tags.every((tag) => /^[a-z0-9][a-z0-9-]{0,39}$/.test(tag));
setInvalid(!valid);
if (valid) { onSave(tags); setEditing(false); }
}}>
<label><span>{label}</span>
<input value={draft} aria-invalid={invalid} onChange={(event) => setDraft(event.target.value)}
placeholder="planning, research" />
</label>
{invalid && <span role="alert">Use up to 32 lowercase tags separated by commas.</span>}
<button type="submit">Save tags</button>
<button type="button" onClick={() => setEditing(false)}>Cancel</button>
</form>;
}
function ProjectList({projects, selectedId, onSelect, onRename, onTags, onPin}: {
projects: readonly Project[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onRename?: OrganizationWorkspaceProps["onRenameProject"];
onTags?: OrganizationWorkspaceProps["onUpdateProjectTags"];
onPin?: OrganizationWorkspaceProps["onToggleProjectPin"];
}) {
return <nav className="hux-org-projects" aria-label="Project folders">
<h2><Folder aria-hidden /> Projects</h2>
<ul>
<li><button type="button" aria-current={selectedId === null ? "page" : undefined}
onClick={() => onSelect(null)}>All conversations</button></li>
{ordered(projects).map((project) => <li key={project.id} data-pinned={project.pinned}>
<button className="hux-org-folder" type="button"
aria-current={selectedId === project.id ? "page" : undefined}
onClick={() => onSelect(project.id)}>{project.name}</button>
{onPin && <button className="hux-org-icon" type="button"
aria-label={`${project.pinned ? "Unpin" : "Pin"} ${project.name}`}
aria-pressed={project.pinned} onClick={() => onPin(project)}><Pin aria-hidden /></button>}
{onRename && <EditableTitle label="Project name" value={project.name} maxLength={120}
onSave={(name) => onRename(project, name)} />}
<EditableTags label="Project tags" values={project.tags}
onSave={onTags ? (tags) => onTags(project, tags) : undefined} />
</li>)}
</ul>
</nav>;
}
function Lineage({conversation, conversations}: {
conversation: Conversation;
conversations: readonly Conversation[];
}) {
const nodes = branchLineage(conversation.id, conversations);
return <section className="hux-org-lineage" aria-labelledby="hux-org-lineage-title">
<h3 id="hux-org-lineage-title"><GitBranch aria-hidden /> Branch lineage</h3>
<ol>
{nodes.map((node) => <li key={`${node.conversationId}-${node.depth}`}
data-missing={node.missing} data-cycle={node.cycle}>
<span>{node.title}</span>
{node.branchPointMessageId && <small>branched at message {node.branchPointMessageId}</small>}
</li>)}
</ol>
</section>;
}
function Attachments({conversation, artifacts}: {
conversation: Conversation;
artifacts: readonly ArtifactSummary[];
}) {
const items = attachmentViews(conversation, artifacts);
return <section className="hux-org-attachments" aria-labelledby="hux-org-attachments-title">
<h3 id="hux-org-attachments-title"><Paperclip aria-hidden /> Attached artifacts</h3>
{items.length ? <ul>{items.map((item) => <li key={item.id} data-missing={item.missing}>
<span>{item.title}</span><small>{item.missing ? "Still attached; details unavailable" : item.kind}</small>
</li>)}</ul> : <p>No artifacts attached.</p>}
</section>;
}
function ConversationList({items, projects, selected, onSelect, onRename, onTags, onMove, onPin}: {
items: readonly Conversation[];
projects: readonly Project[];
selected: string | null;
onSelect: (id: string) => void;
onRename?: OrganizationWorkspaceProps["onRenameConversation"];
onTags?: OrganizationWorkspaceProps["onUpdateConversationTags"];
onMove?: OrganizationWorkspaceProps["onMoveConversation"];
onPin?: OrganizationWorkspaceProps["onToggleConversationPin"];
}) {
return <ul className="hux-org-conversations" aria-label="Conversations">
{ordered(items).map((conversation) => <li key={conversation.id}
data-selected={selected === conversation.id} data-pinned={conversation.pinned}>
<div className="hux-org-conversation-title">
<button type="button" onClick={() => onSelect(conversation.id)}
aria-pressed={selected === conversation.id}>{conversation.title}</button>
{onPin && <button className="hux-org-icon" type="button"
aria-label={`${conversation.pinned ? "Unpin" : "Pin"} ${conversation.title}`}
aria-pressed={conversation.pinned} onClick={() => onPin(conversation)}><Pin aria-hidden /></button>}
</div>
{onRename && <EditableTitle label="Conversation title" value={conversation.title} maxLength={200}
onSave={(title) => onRename(conversation, title)} />}
<EditableTags label="Conversation tags" values={conversation.tags}
onSave={onTags ? (tags) => onTags(conversation, tags) : undefined} />
<a className="hux-org-resume" href={`/session/${encodeURIComponent(conversation.id)}`}>
Resume conversation
</a>
{onMove && <label className="hux-org-move">Move to
<select value={conversation.projectId || ""}
onChange={(event) => onMove(conversation, event.target.value || null)}>
<option value="">No project</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>}
</li>)}
</ul>;
}
/** Render nothing unless the negotiated hux.foundation and hux.projects flags are enabled. */
export function OrganizationWorkspace(props: OrganizationWorkspaceProps) {
const [projectId, setProjectId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState("");
const enabled = projectsEnabled(props.flags);
const baseItems = props.searchResults || props.conversations;
const items = useMemo(() => projectId === null ? baseItems :
baseItems.filter((item) => item.projectId === projectId), [baseItems, projectId]);
const selected = props.conversations.find((item) => item.id === selectedId) ||
items[0] || null;
if (!enabled) return null;
function submitSearch(event: FormEvent) {
event.preventDefault();
const value = query.trim();
if (value) props.onSearch?.(value, null);
}
return <section className="hux-organization" aria-label="Projects and conversations" aria-busy={props.busy}>
<ProjectList projects={props.projects} selectedId={projectId} onSelect={setProjectId}
onRename={props.onRenameProject} onTags={props.onUpdateProjectTags}
onPin={props.onToggleProjectPin} />
<main className="hux-org-main">
<header><h2>Conversations</h2>
<form role="search" onSubmit={submitSearch}>
<label htmlFor="hux-org-search">Search conversations, messages, projects, and artifacts</label>
<div><Search aria-hidden /><input id="hux-org-search" type="search" maxLength={200}
value={query} onChange={(event) => setQuery(event.target.value)} />
<button type="submit" disabled={!query.trim() || props.busy}>Search</button></div>
</form>
</header>
{props.error && <p className="hux-org-error" role="alert">{props.error}</p>}
<p className="sr-only" role="status" aria-live="polite">
{props.busy ? "Loading conversations" : `${items.length} conversations shown`}
</p>
<ConversationList items={items} projects={props.projects} selected={selected?.id || null}
onSelect={setSelectedId} onRename={props.onRenameConversation}
onTags={props.onUpdateConversationTags} onMove={props.onMoveConversation}
onPin={props.onToggleConversationPin} />
{!items.length && <p className="hux-org-empty">No matching conversations.</p>}
{props.nextSearchCursor && props.onSearch && <button className="hux-org-more" type="button"
disabled={props.busy} onClick={() => props.onSearch?.(query.trim(), props.nextSearchCursor || null)}>
Load more results
</button>}
</main>
<aside className="hux-org-details" aria-label="Conversation details">
{selected ? <><Lineage conversation={selected} conversations={props.conversations} />
<Attachments conversation={selected} artifacts={props.artifacts || []} /></> :
<p>Select a conversation to inspect its lineage and attachments.</p>}
</aside>
</section>;
}

View File

@ -0,0 +1,253 @@
/** Same-origin, capability-gated transport for HUX-03. */
import { ACCEPT, API_VERSION, RESPONSE_SCHEMAS } from "./contracts.ts";
import { normalizeConversation, normalizeProject, preservesConversation } from "./model.ts";
import {
boundedLimit,
isOpaqueId,
isUtc,
normalizeIdentity,
safeCursor,
safeTags,
sameIdentity,
scopedPath,
} from "./security.ts";
import type {
Conversation,
ConversationPatch,
Page,
Project,
ProjectPatch,
RawConversation,
RawEnvelope,
RawProject,
ScopedIdentity,
SearchFilters,
} from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: ScopedIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
interface FetchResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
interface ClientOptions {
client: FoundationClient;
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
}
interface RawMutationEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
item?: unknown;
}
export class OrganizationContractError extends Error {
constructor(message: string) {
super(message);
this.name = "OrganizationContractError";
}
}
function assertAvailable(client: FoundationClient): void {
if (client.apiVersion !== API_VERSION || !client.enabled("hux.foundation") ||
!client.enabled("hux.projects")) {
throw new OrganizationContractError("Project organization is not enabled");
}
}
function assertEnvelope(raw: unknown, schema: string, expected: ScopedIdentity): RawEnvelope {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new OrganizationContractError("Invalid organization response");
}
const value = raw as RawEnvelope;
const identity = normalizeIdentity(value.identity);
if (value.schema !== schema || value.api_version !== API_VERSION || !identity ||
!sameIdentity(identity, expected)) {
throw new OrganizationContractError("Organization response crossed its scope");
}
return value;
}
function pagination(raw: RawEnvelope): Pick<Page<never>, "nextCursor" | "total"> {
const cursor = safeCursor(raw.next_cursor);
if (cursor === undefined || (raw.total !== null && raw.total !== undefined &&
(!Number.isSafeInteger(raw.total) || (raw.total as number) < 0))) {
throw new OrganizationContractError("Invalid organization pagination");
}
return {nextCursor: cursor || null, total: raw.total as number | null | undefined ?? null};
}
function pageQuery(cursor: string | null | undefined, limit: number | undefined): URLSearchParams {
const query = new URLSearchParams();
const safe = safeCursor(cursor);
if (safe === undefined) throw new TypeError("Invalid organization cursor");
if (safe) query.set("cursor", safe);
query.set("limit", String(boundedLimit(limit)));
return query;
}
function projectPage(raw: unknown, expected: ScopedIdentity): Page<Project> {
const value = assertEnvelope(raw, RESPONSE_SCHEMAS.projects, expected);
if (!Array.isArray(value.items) || value.items.length > 100) {
throw new OrganizationContractError("Project page exceeds its bound");
}
const items = value.items.map((item) => normalizeProject(item as RawProject, expected.userRef));
if (items.some((item) => item === null) ||
new Set(items.map((item) => item?.id)).size !== items.length) {
throw new OrganizationContractError("Invalid project record");
}
return {...pagination(value), items: items as Project[]};
}
function conversationPage(
raw: unknown,
schema: string,
expected: ScopedIdentity,
projectIds: ReadonlySet<string>,
): Page<Conversation> {
const value = assertEnvelope(raw, schema, expected);
if (!Array.isArray(value.items) || value.items.length > 100) {
throw new OrganizationContractError("Conversation page exceeds its bound");
}
const items = value.items.map((item) =>
normalizeConversation(item as RawConversation, expected.userRef, projectIds));
if (items.some((item) => item === null) ||
new Set(items.map((item) => item?.id)).size !== items.length) {
throw new OrganizationContractError("Invalid conversation record");
}
return {...pagination(value), items: items as Conversation[]};
}
function mutationItem(
raw: unknown,
schema: string,
expected: ScopedIdentity,
): unknown {
const value = assertEnvelope(raw, schema, expected) as RawMutationEnvelope;
if (!("item" in value) || value.item === undefined) {
throw new OrganizationContractError("Mutation response has no item");
}
return value.item;
}
export function createOrganizationClient(options: ClientOptions) {
const client = options.client;
const fetcher = options.fetcher || (typeof fetch === "function" ? fetch.bind(globalThis) : null);
if (!client || !fetcher) throw new TypeError("Organization client requires HUX and fetch clients");
const identity = client.identity;
async function request(path: string, init: Record<string, unknown> = {}): Promise<unknown> {
assertAvailable(client);
const response = await fetcher(client.endpoint(path), {
cache: "no-store", credentials: "same-origin",
headers: {Accept: ACCEPT, ...(init.body ? {"Content-Type": "application/json"} : {})},
...init,
});
if (!response.ok) throw new OrganizationContractError(
response.status === 409 ? "This item changed; refresh before trying again" :
`Organization request failed (${response.status})`,
);
return response.json();
}
async function listProjects(cursor?: string | null, limit?: number): Promise<Page<Project>> {
const query = pageQuery(cursor, limit);
return projectPage(await request(`/projects?${query}`), identity);
}
async function listConversations(
projectId: string,
projectIds: ReadonlySet<string>,
cursor?: string | null,
limit?: number,
): Promise<Page<Conversation>> {
if (!projectIds.has(projectId)) throw new TypeError("Project is outside this workspace");
const path = `${scopedPath("/projects", projectId)}/conversations?${pageQuery(cursor, limit)}`;
const page = conversationPage(await request(path), RESPONSE_SCHEMAS.conversations, identity, projectIds);
if (page.items.some((item) => item.projectId !== projectId)) {
throw new OrganizationContractError("Conversation crossed its project boundary");
}
return page;
}
async function searchConversations(
filters: SearchFilters,
projectIds: ReadonlySet<string>,
): Promise<Page<Conversation>> {
const text = typeof filters.query === "string" ? filters.query.trim() : "";
if (!text || text.length > 200) throw new TypeError("Search query must be 1 to 200 characters");
const query = pageQuery(filters.cursor, filters.limit);
query.set("q", text);
if (filters.projectId) {
if (!projectIds.has(filters.projectId)) throw new TypeError("Search project is outside this workspace");
query.set("project_id", filters.projectId);
}
const tags = safeTags(filters.tags || []);
if (tags === null) throw new TypeError("Search tags are invalid");
tags.forEach((tag) => query.append("tag", tag));
if (filters.pinned !== undefined) query.set("pinned", String(filters.pinned));
return conversationPage(await request(`/conversations/search?${query}`),
RESPONSE_SCHEMAS.search, identity, projectIds);
}
async function updateProject(before: Project, patch: ProjectPatch): Promise<Project> {
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt)) {
throw new TypeError("Project mutation is outside this workspace");
}
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
const name = patch.name === undefined ? undefined : patch.name.trim();
if (tags === null || (name !== undefined && (!name || name.length > 120))) {
throw new TypeError("Project mutation is invalid");
}
const body = {expected_updated_at: patch.expectedUpdatedAt,
...(name !== undefined ? {name} : {}), ...(tags !== undefined ? {tags} : {}),
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
const raw = mutationItem(await request(scopedPath("/projects", before.id),
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.projectMutation, identity);
const item = normalizeProject(raw as RawProject, identity.userRef);
if (!item || item.id !== before.id) throw new OrganizationContractError("Project identity changed during mutation");
return item;
}
async function updateConversation(
before: Conversation,
patch: ConversationPatch,
projectIds: ReadonlySet<string>,
): Promise<Conversation> {
if (before.owner !== identity.userRef || !isUtc(patch.expectedUpdatedAt) ||
(before.projectId !== null && !projectIds.has(before.projectId)) ||
(patch.projectId !== undefined && patch.projectId !== null && !projectIds.has(patch.projectId))) {
throw new TypeError("Conversation mutation is outside this workspace");
}
const tags = patch.tags === undefined ? undefined : safeTags(patch.tags);
const title = patch.title === undefined ? undefined : patch.title.trim();
if (tags === null || (title !== undefined && (!title || title.length > 200))) {
throw new TypeError("Conversation mutation is invalid");
}
const body = {expected_updated_at: patch.expectedUpdatedAt,
...(title !== undefined ? {title} : {}),
...(patch.projectId !== undefined ? {project_id: patch.projectId} : {}),
...(tags !== undefined ? {tags} : {}),
...(patch.pinned !== undefined ? {pinned: patch.pinned} : {})};
const raw = mutationItem(await request(scopedPath("/conversations", before.id),
{method: "PATCH", body: JSON.stringify(body)}), RESPONSE_SCHEMAS.conversationMutation, identity);
const item = normalizeConversation(raw as RawConversation, identity.userRef, projectIds);
const intendedProject = patch.projectId === undefined ? before.projectId : patch.projectId;
if (!item || item.projectId !== intendedProject || !preservesConversation(before, item)) {
throw new OrganizationContractError("Conversation lineage or attachments changed during mutation");
}
return item;
}
return Object.freeze({listConversations, listProjects, searchConversations,
updateConversation, updateProject});
}

View File

@ -0,0 +1,38 @@
/** Exact same-origin backend contracts required by the HUX-03 frontend. */
export const API_VERSION = "hux.v1";
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
export const RESPONSE_SCHEMAS = Object.freeze({
projects: "hux.projects.page.v1",
conversations: "hux.conversations.page.v1",
search: "hux.conversations.search.v1",
projectMutation: "hux.project.response.v1",
conversationMutation: "hux.conversation.response.v1",
});
/**
* GET /hux/v1/projects?cursor=&limit=
* GET /hux/v1/projects/{prj_id}/conversations?cursor=&limit=
* GET /hux/v1/conversations/search?q=&project_id=&tag=&pinned=&cursor=&limit=
* PATCH /hux/v1/projects/{prj_id}
* PATCH /hux/v1/conversations/{conv_id}
*
* Every response is JSON with api_version="hux.v1", its operation-specific
* schema, and identity={tenant_ref,user_ref,surface}. Page responses contain
* items, next_cursor (opaque or null), and total (non-negative integer or
* null). Mutation responses contain item. PATCH bodies use only name/title,
* project_id, tags, pinned, and required expected_updated_at for optimistic
* concurrency. Identity is derived from the authenticated server session and
* MUST NOT be accepted from a query, path, body, or browser storage. Moves MUST
* preserve conversation id, branch, and artifact_ids atomically. A 409 means
* stale expected_updated_at; 404 is not converted into an unscoped lookup.
*/
export const BACKEND_ENDPOINTS = Object.freeze({
projects: "/projects",
projectConversations: "/projects/{prj_id}/conversations",
search: "/conversations/search",
project: "/projects/{prj_id}",
conversation: "/conversations/{conv_id}",
});

View File

@ -0,0 +1,14 @@
export { createOrganizationClient, OrganizationContractError } from "./client.ts";
export { OrganizationWorkspace } from "./OrganizationWorkspace.tsx";
export {
attachmentViews,
branchLineage,
normalizeConversation,
normalizeProject,
normalizeSnapshot,
preservesConversation,
projectsEnabled,
} from "./model.ts";
export { BACKEND_ENDPOINTS, RESPONSE_SCHEMAS } from "./contracts.ts";
export type * from "./types.ts";

View File

@ -0,0 +1,165 @@
/** Schema-aligned normalization and lineage invariants for HUX-03. */
import { isOpaqueId, isUtc, safeTags, safeText } from "./security.ts";
import type {
ArtifactSummary,
AttachmentView,
Conversation,
LineageNode,
OrganizationSnapshot,
Project,
RawConversation,
RawProject,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PROJECTS_FLAG = "hux.projects";
const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]);
export function projectsEnabled(flags?: Iterable<string>): boolean {
const enabled = new Set(flags || []);
return enabled.has(FOUNDATION_FLAG) && enabled.has(PROJECTS_FLAG);
}
export function normalizeProject(raw: RawProject, owner: string): Project | null {
const tags = safeTags(raw.tags);
if (raw.schema !== "hux.project.v1" || !isOpaqueId(raw.id, "prj") ||
raw.owner !== owner || tags === null || typeof raw.pinned !== "boolean" ||
typeof raw.archived !== "boolean" || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || (raw.default_mode !== undefined &&
(typeof raw.default_mode !== "string" || !MODES.has(raw.default_mode)))) return null;
const name = safeText(raw.name, "", 120);
if (!name) return null;
return {
id: raw.id, owner, name,
description: safeText(raw.description, "", 2000), tags,
pinned: raw.pinned, archived: raw.archived, updatedAt: raw.updated_at,
};
}
function normalizeBranch(raw: unknown): Conversation["branch"] | undefined {
if (raw === undefined) return null;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
if (!isOpaqueId(value.parent_conversation_id, "conv") ||
typeof value.branch_point_message_id !== "string") return undefined;
const messageId = safeText(value.branch_point_message_id, "", 120);
return messageId ? {
parentConversationId: value.parent_conversation_id,
branchPointMessageId: messageId,
} : undefined;
}
export function normalizeConversation(
raw: RawConversation,
owner: string,
projectIds: ReadonlySet<string>,
): Conversation | null {
const tags = safeTags(raw.tags);
const branch = normalizeBranch(raw.branch);
const artifacts = Array.isArray(raw.artifact_ids) ? raw.artifact_ids : null;
const projectId = raw.project_id === undefined ? null : raw.project_id;
if (raw.schema !== "hux.conversation.v1" || !isOpaqueId(raw.id, "conv") ||
raw.owner !== owner || tags === null || branch === undefined ||
typeof raw.pinned !== "boolean" || typeof raw.archived !== "boolean" ||
!artifacts || artifacts.length > 500 || new Set(artifacts).size !== artifacts.length ||
artifacts.some((id) => !isOpaqueId(id, "art")) || !isUtc(raw.created_at) ||
!isUtc(raw.updated_at) || (raw.last_message_at !== undefined &&
!isUtc(raw.last_message_at)) || (raw.mode !== undefined &&
(typeof raw.mode !== "string" || !MODES.has(raw.mode))) ||
(projectId !== null && (!isOpaqueId(projectId, "prj") || !projectIds.has(projectId)))) return null;
const title = safeText(raw.title, "", 200);
if (!title || branch?.parentConversationId === raw.id) return null;
return {
id: raw.id, owner, projectId, title, tags, pinned: raw.pinned,
archived: raw.archived, artifactIds: [...artifacts] as string[], branch,
lastMessageAt: raw.last_message_at as string | undefined || null,
updatedAt: raw.updated_at,
};
}
/** Reject cross-owner/project records instead of silently re-homing them. */
export function normalizeSnapshot(
rawProjects: readonly RawProject[],
rawConversations: readonly RawConversation[],
owner: string,
): OrganizationSnapshot {
const seenProjects = new Set<string>();
const projects = rawProjects.slice(0, 100).map((item) => normalizeProject(item, owner))
.filter((item): item is Project => {
if (!item || seenProjects.has(item.id)) return false;
seenProjects.add(item.id);
return true;
});
const projectIds = new Set(projects.map((item) => item.id));
const seenConversations = new Set<string>();
const conversations = rawConversations.slice(0, 500)
.map((item) => normalizeConversation(item, owner, projectIds))
.filter((item): item is Conversation => {
if (!item || seenConversations.has(item.id)) return false;
seenConversations.add(item.id);
return true;
});
return {
projects, conversations,
rejected: Math.max(0, rawProjects.length - projects.length) +
Math.max(0, rawConversations.length - conversations.length),
};
}
/** Missing metadata stays visible as an attachment placeholder. */
export function attachmentViews(
conversation: Conversation,
summaries: readonly ArtifactSummary[],
): AttachmentView[] {
const byId = new Map(summaries.filter((item) =>
isOpaqueId(item.id, "art")).slice(0, 500).map((item) => [item.id, item]));
return conversation.artifactIds.map((id) => {
const found = byId.get(id);
return found ? {
id, title: safeText(found.title, "Untitled artifact", 200),
kind: safeText(found.kind, "artifact", 40), missing: false,
} : {id, title: "Attachment details unavailable", kind: "artifact", missing: true};
});
}
/** Build bounded ancestry with explicit missing/cycle nodes, never a fake root. */
export function branchLineage(
selectedId: string,
conversations: readonly Conversation[],
): LineageNode[] {
if (!isOpaqueId(selectedId, "conv")) return [];
const byId = new Map(conversations.slice(0, 500).map((item) => [item.id, item]));
const result: LineageNode[] = [];
const visited = new Set<string>();
let currentId: string | null = selectedId;
while (currentId && result.length < 64) {
if (visited.has(currentId)) {
result.push({conversationId: currentId, title: "Circular branch reference", parentConversationId: null,
branchPointMessageId: null, depth: result.length, missing: false, cycle: true});
break;
}
visited.add(currentId);
const item = byId.get(currentId);
if (!item) {
result.push({conversationId: currentId, title: "Earlier conversation unavailable",
parentConversationId: null, branchPointMessageId: null, depth: result.length,
missing: true, cycle: false});
break;
}
result.push({conversationId: item.id, title: item.title,
parentConversationId: item.branch?.parentConversationId || null,
branchPointMessageId: item.branch?.branchPointMessageId || null,
depth: result.length, missing: false, cycle: false});
currentId = item.branch?.parentConversationId || null;
}
return result.reverse().map((item, depth) => ({...item, depth}));
}
/** A move/rename may not change stable lineage or orphan any attachment. */
export function preservesConversation(before: Conversation, after: Conversation): boolean {
return before.id === after.id && before.owner === after.owner &&
JSON.stringify(before.branch) === JSON.stringify(after.branch) &&
before.artifactIds.length === after.artifactIds.length &&
before.artifactIds.every((id, index) => after.artifactIds[index] === id);
}

View File

@ -0,0 +1,78 @@
/** Identity, text, URL, and collection guards for HUX-03. */
import type { RawIdentity, ScopedIdentity } from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const CURSOR = /^[A-Za-z0-9._~-]{1,256}$/;
const TAG = /^[a-z0-9][a-z0-9-]{0,39}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/g;
const SURFACES = new Set(["chat", "worker", "telegram", "voice", "api"]);
export function isOpaqueId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) &&
!Number.isNaN(Date.parse(value));
}
export function safeText(value: unknown, fallback: string, limit: number): string {
if (typeof value !== "string") return fallback;
const clean = value.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
if (!clean) return fallback;
return clean.length <= limit ? clean : `${clean.slice(0, limit - 1).trimEnd()}…`;
}
export function safeTags(value: unknown): string[] | null {
if (!Array.isArray(value) || value.length > 32) return null;
const tags = value.filter((tag): tag is string => typeof tag === "string");
if (tags.length !== value.length || tags.some((tag) => !TAG.test(tag))) return null;
const unique = [...new Set(tags)];
return unique.length === tags.length ? unique : null;
}
export function normalizeIdentity(raw: unknown): ScopedIdentity | null {
const value = raw as RawIdentity | null;
if (!value || typeof value !== "object") return null;
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref)) return null;
if (typeof value.user_ref !== "string" || !USER.test(value.user_ref)) return null;
if (typeof value.surface !== "string" || !SURFACES.has(value.surface)) return null;
return {
tenantRef: value.tenant_ref,
userRef: value.user_ref,
surface: value.surface as ScopedIdentity["surface"],
};
}
export function sameIdentity(left: ScopedIdentity, right: ScopedIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function safeCursor(value: unknown): string | null | undefined {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" && CURSOR.test(value) ? value : undefined;
}
export function scopedPath(base: string, ...parts: string[]): string {
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) || base.includes("..")) {
throw new TypeError("HUX base URL must be a same-origin path");
}
const cleanBase = base.replace(/\/$/, "");
const encoded = parts.map((part) => {
if (!isOpaqueId(part)) throw new TypeError("HUX path identifier is invalid");
return encodeURIComponent(part);
});
return [cleanBase, ...encoded].join("/");
}
export function boundedLimit(value: unknown, fallback = 40): number {
return Number.isInteger(value) && (value as number) > 0 ?
Math.min(value as number, 100) : fallback;
}

View File

@ -0,0 +1,89 @@
.hux-organization {
display: grid;
grid-template-columns: minmax(10rem, 0.7fr) minmax(18rem, 1.5fr) minmax(14rem, 1fr);
gap: 0.75rem;
color: var(--hux-fg, #dbe8ef);
}
.hux-organization > * {
min-width: 0;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.8rem;
padding: 0.8rem;
background: color-mix(in srgb, var(--hux-bg, #0b121c) 94%, transparent);
}
.hux-organization h2,
.hux-organization h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.65rem;
}
.hux-organization svg { width: 1rem; height: 1rem; }
.hux-organization ul,
.hux-organization ol { margin: 0; padding: 0; list-style: none; }
.hux-org-projects li,
.hux-org-conversations > li,
.hux-org-lineage li,
.hux-org-attachments li { margin-top: 0.45rem; }
.hux-org-projects button[aria-current="page"],
.hux-org-conversations li[data-selected="true"] {
color: var(--hux-accent, #22d3ee);
background: color-mix(in srgb, var(--hux-accent, #22d3ee) 9%, transparent);
}
.hux-org-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.hux-org-tags li {
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.67rem;
}
.hux-org-conversation-title,
.hux-org-main form div { display: flex; gap: 0.4rem; align-items: center; }
.hux-org-icon { padding: 0.2rem; }
.hux-org-icon[aria-pressed="false"] { opacity: 0.45; }
.hux-org-resume,
.hux-org-move { display: block; margin-top: 0.35rem; font-size: 0.75rem; }
.hux-org-move select { margin-left: 0.4rem; }
.hux-org-rename { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
.hux-org-lineage li { position: relative; padding-left: 1rem; }
.hux-org-lineage li::before {
position: absolute;
left: 0.2rem;
top: -0.5rem;
bottom: -0.5rem;
width: 1px;
background: color-mix(in srgb, var(--hux-accent, #22d3ee) 50%, transparent);
content: "";
}
.hux-org-lineage li::after {
position: absolute; left: 0; top: 0.45rem; width: 0.45rem; height: 0.45rem;
border-radius: 50%; background: var(--hux-accent, #22d3ee); content: "";
}
.hux-org-lineage small,
.hux-org-attachments small { display: block; opacity: 0.6; }
.hux-org-lineage li[data-missing="true"],
.hux-org-lineage li[data-cycle="true"],
.hux-org-attachments li[data-missing="true"] { color: var(--hux-warning, #fbbf24); }
.hux-org-error { color: var(--hux-danger, #fb7185); }
@media (max-width: 62rem) {
.hux-organization { grid-template-columns: minmax(10rem, 0.7fr) minmax(18rem, 1.5fr); }
.hux-org-details { grid-column: 1 / -1; }
}
@media (max-width: 42rem) {
.hux-organization { grid-template-columns: 1fr; }
.hux-org-details { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
.hux-organization * { scroll-behavior: auto; transition: none; }
}

View File

@ -0,0 +1,143 @@
/** Safe frontend and transport types for HUX-03 project organization. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export interface ScopedIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface RawIdentity {
tenant_ref?: unknown;
user_ref?: unknown;
surface?: unknown;
}
export interface RawProject {
schema?: unknown;
id?: unknown;
owner?: unknown;
name?: unknown;
description?: unknown;
tags?: unknown;
pinned?: unknown;
archived?: unknown;
default_mode?: unknown;
memory_scope_id?: unknown;
created_at?: unknown;
updated_at?: unknown;
}
export interface RawConversation {
schema?: unknown;
id?: unknown;
owner?: unknown;
project_id?: unknown;
title?: unknown;
tags?: unknown;
pinned?: unknown;
archived?: unknown;
mode?: unknown;
branch?: unknown;
artifact_ids?: unknown;
last_message_at?: unknown;
created_at?: unknown;
updated_at?: unknown;
}
export interface Project {
id: string;
owner: string;
name: string;
description: string;
tags: string[];
pinned: boolean;
archived: boolean;
updatedAt: string;
}
export interface ConversationBranch {
parentConversationId: string;
branchPointMessageId: string;
}
export interface Conversation {
id: string;
owner: string;
projectId: string | null;
title: string;
tags: string[];
pinned: boolean;
archived: boolean;
artifactIds: string[];
branch: ConversationBranch | null;
lastMessageAt: string | null;
updatedAt: string;
}
export interface ArtifactSummary {
id: string;
title: string;
kind: string;
}
export interface AttachmentView extends ArtifactSummary {
missing: boolean;
}
export interface LineageNode {
conversationId: string;
title: string;
parentConversationId: string | null;
branchPointMessageId: string | null;
depth: number;
missing: boolean;
cycle: boolean;
}
export interface Page<T> {
items: T[];
nextCursor: string | null;
total: number | null;
}
export interface RawEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
items?: unknown;
next_cursor?: unknown;
total?: unknown;
}
export interface OrganizationSnapshot {
projects: Project[];
conversations: Conversation[];
rejected: number;
}
export interface SearchFilters {
query: string;
projectId?: string | null;
tags?: readonly string[];
pinned?: boolean;
cursor?: string | null;
limit?: number;
}
export interface ConversationPatch {
title?: string;
projectId?: string | null;
tags?: readonly string[];
pinned?: boolean;
expectedUpdatedAt: string;
}
export interface ProjectPatch {
name?: string;
tags?: readonly string[];
pinned?: boolean;
expectedUpdatedAt: string;
}

View File

@ -0,0 +1,165 @@
/** Accessible HUX-10 privacy status and explicit conversation controls. */
import {useEffect, useState} from "react";
import {privacyControls, privacyEnabled} from "./model.ts";
import type {
PrivacyClient,
PrivacyControl,
PrivacySnapshot,
} from "./types.ts";
interface PrivacyCenterProps {
flags?: Iterable<string>;
client: PrivacyClient;
snapshot: PrivacySnapshot | null;
disabled?: boolean;
onUpdated(snapshot: PrivacySnapshot, action: PrivacyControl): void;
}
const CONTROL_LABELS: Readonly<Record<PrivacyControl, string>> = {
enable_no_store: "Use no-store here",
disable_no_store: "Use standard retention",
forget_this_conversation: "Forget this conversation",
switch_to_private: "Switch to Private mode",
disable_memory_here: "Disable memory here",
dismiss: "Dismiss notice",
};
function readableTime(timestamp: string): string {
return timestamp.replace("T", " ").replace("Z", " UTC");
}
/** Render nothing unless both HUX-11 and HUX-10 are explicitly enabled. */
export function PrivacyCenter(props: PrivacyCenterProps) {
const [busy, setBusy] = useState<PrivacyControl | null>(null);
const [error, setError] = useState("");
const [confirmForget, setConfirmForget] = useState(false);
const enabled = privacyEnabled(props.flags) && props.client?.enabled();
const snapshot = props.snapshot;
useEffect(() => {
setBusy(null);
setError("");
setConfirmForget(false);
}, [snapshot?.revision]);
if (!enabled || !snapshot) return null;
const controls = privacyControls(snapshot);
const ordinaryControls = controls.filter((control) =>
control !== "forget_this_conversation" && control !== "dismiss");
async function apply(action: PrivacyControl): Promise<void> {
if (busy || props.disabled) return;
setBusy(action);
setError("");
try {
const updated = await props.client.control(snapshot, action);
props.onUpdated(updated, action);
} catch (_) {
setError("The privacy change could not be verified. Nothing was changed in this view.");
} finally {
setBusy(null);
setConfirmForget(false);
}
}
return (
<section className="hux-privacy" aria-labelledby="hux-privacy-heading">
<header className="hux-privacy-heading">
<div>
<span className="hux-privacy-kicker">Conversation privacy</span>
<h2 id="hux-privacy-heading">What Hermes keeps</h2>
</div>
<span className={`hux-privacy-state ${snapshot.noStore ? "is-private" : ""}`}
aria-live="polite">
{snapshot.noStore ? "No-store active" : "Standard retention"}
</span>
</header>
{snapshot.notice && (
<aside className="hux-privacy-notice" aria-labelledby="hux-privacy-notice-title">
<h3 id="hux-privacy-notice-title">Sensitive-topic protection</h3>
<p>{snapshot.notice.text}</p>
{snapshot.notice.controls.includes("dismiss") && (
<button type="button" disabled={Boolean(busy) || props.disabled}
onClick={() => void apply("dismiss")}>Dismiss notice</button>
)}
</aside>
)}
<dl className="hux-privacy-retention" aria-label="Effective retention">
<div>
<dt>Conversation</dt>
<dd>{snapshot.noStore ? "Session only; not retained afterward" :
"Retained under the workspace policy"}</dd>
</div>
<div>
<dt>Memory</dt>
<dd>{snapshot.memoryEnabled ? "Ask before saving sensitive facts" :
"Memory writes disabled here"}</dd>
</div>
<div>
<dt>Sensitive context</dt>
<dd>{snapshot.retention.topicContextDecayDays === null ?
"Not retained beyond this session" :
`Minimized within ${snapshot.retention.topicContextDecayDays} days`}</dd>
</div>
<div>
<dt>After the topic changes</dt>
<dd>Previous sensitive details are not repeated</dd>
</div>
<div>
<dt>Across surfaces</dt>
<dd>{snapshot.retention.crossSurfaceSharing === "never" ?
"Never shared" : "Same owner only"}</dd>
</div>
<div>
<dt>Retention audit</dt>
<dd>Every {snapshot.retention.auditIntervalDays} days; next{" "}
<time dateTime={snapshot.retention.nextAuditAt}>
{readableTime(snapshot.retention.nextAuditAt)}
</time>
</dd>
</div>
</dl>
<div className="hux-privacy-controls" aria-busy={Boolean(busy)}>
{ordinaryControls.map((control) => (
<button key={control} type="button" disabled={Boolean(busy) || props.disabled}
onClick={() => void apply(control)}>
{CONTROL_LABELS[control]}
</button>
))}
{controls.includes("forget_this_conversation") && !confirmForget && (
<button type="button" className="hux-privacy-danger"
disabled={Boolean(busy) || props.disabled} onClick={() => setConfirmForget(true)}>
Forget this conversation
</button>
)}
</div>
{confirmForget && (
<div className="hux-privacy-confirm" role="group"
aria-labelledby="hux-privacy-confirm-title">
<strong id="hux-privacy-confirm-title">Remove this conversation and its saved context?</strong>
<span>This cannot be undone.</span>
<div>
<button type="button" className="hux-privacy-danger"
disabled={Boolean(busy) || props.disabled}
onClick={() => void apply("forget_this_conversation")}>
Confirm forget
</button>
<button type="button" disabled={Boolean(busy)}
onClick={() => setConfirmForget(false)}>Cancel</button>
</div>
</div>
)}
<div className="hux-privacy-status" role="status" aria-live="polite">
{busy ? "Applying privacy control…" : ""}
</div>
{error && <p className="hux-privacy-error" role="alert">{error}</p>}
</section>
);
}

View File

@ -0,0 +1,130 @@
/** Same-origin, scope-bound transport for HUX-10 privacy state. */
import {ACCEPT, API_VERSION} from "./contracts.ts";
import {
FOUNDATION_FLAG,
PRIVACY_FLAG,
normalizeSnapshot,
privacyControls,
withoutNotice,
} from "./model.ts";
import {
normalizeIdentity,
normalizeScope,
privacyQuery,
sameIdentity,
} from "./security.ts";
import type {
FetchResponse,
HuxFoundationClient,
PrivacyControl,
PrivacyScope,
PrivacySnapshot,
} from "./types.ts";
interface ClientOptions {
client: HuxFoundationClient;
fetcher?: (url: string, init: Record<string, unknown>) => Promise<FetchResponse>;
}
export class PrivacyContractError extends Error {
constructor(message: string) {
super(message);
this.name = "PrivacyContractError";
}
}
function enabled(client: HuxFoundationClient): boolean {
return client.apiVersion === API_VERSION && client.enabled(FOUNDATION_FLAG) &&
client.enabled(PRIVACY_FLAG);
}
function effectMatches(action: PrivacyControl, snapshot: PrivacySnapshot): boolean {
if (action === "enable_no_store" || action === "switch_to_private")
return snapshot.noStore;
if (action === "disable_no_store") return !snapshot.noStore;
if (action === "disable_memory_here") return !snapshot.memoryEnabled;
if (action === "dismiss") return snapshot.notice === null;
return action === "forget_this_conversation" && snapshot.noStore &&
!snapshot.memoryEnabled && snapshot.notice === null;
}
export function createPrivacyClient(options: ClientOptions) {
const client = options?.client;
const fetcher = options?.fetcher ||
(typeof fetch === "function" ? fetch.bind(globalThis) : null);
if (!client || !fetcher) throw new TypeError("Privacy requires HUX and fetch clients");
const identity = normalizeIdentity({
tenant_ref: client.identity?.tenantRef,
user_ref: client.identity?.userRef,
surface: client.identity?.surface,
});
if (!identity || !sameIdentity(identity, client.identity))
throw new TypeError("Privacy requires a scoped HUX identity");
const current = new Map<string, PrivacySnapshot>();
const seenNotices = new Set<string>();
function scopeKey(scope: PrivacyScope): string {
return `${scope.sessionId}\u0000${scope.conversationId}`;
}
function minimizeRepeatedNotice(snapshot: PrivacySnapshot): PrivacySnapshot {
if (!snapshot.notice) return snapshot;
const key = `${snapshot.scope.conversationId}\u0000${snapshot.notice.shownAt}`;
if (seenNotices.has(key)) return withoutNotice(snapshot);
seenNotices.add(key);
return snapshot;
}
async function request(path: string, init: Record<string, unknown>): Promise<unknown> {
if (!enabled(client)) throw new PrivacyContractError("Privacy controls are not enabled");
const response = await fetcher(client.endpoint(path), {
cache: "no-store", credentials: "same-origin",
headers: {Accept: ACCEPT, ...(init.body ? {"Content-Type": "application/json"} : {})},
...init,
});
if (!response.ok) throw new PrivacyContractError(
response.status === 409 ? "Privacy state changed; refresh before trying again" :
`Privacy request failed (${response.status})`,
);
return response.json();
}
async function load(scope: PrivacyScope): Promise<PrivacySnapshot> {
const safeScope = normalizeScope(scope);
if (!safeScope) throw new TypeError("Privacy scope is invalid");
const raw = await request(privacyQuery(safeScope), {method: "GET"});
const snapshot = normalizeSnapshot(raw, identity, safeScope);
if (!snapshot) throw new PrivacyContractError("Privacy response crossed its scope");
const visible = minimizeRepeatedNotice(snapshot);
current.set(scopeKey(safeScope), visible);
return visible;
}
async function control(
snapshot: PrivacySnapshot,
action: PrivacyControl,
): Promise<PrivacySnapshot> {
const key = snapshot?.scope ? scopeKey(snapshot.scope) : "";
if (current.get(key) !== snapshot || !privacyControls(snapshot).includes(action))
throw new TypeError("Privacy control is not bound to the current conversation");
const raw = await request("/privacy/controls", {
method: "POST",
body: JSON.stringify({
schema: "hux.privacy_control_request.v1",
session_id: snapshot.scope.sessionId,
conversation_id: snapshot.scope.conversationId,
expected_revision: snapshot.revision,
action,
}),
});
const updated = normalizeSnapshot(raw, identity, snapshot.scope);
if (!updated || updated.revision <= snapshot.revision || !effectMatches(action, updated))
throw new PrivacyContractError("Privacy update could not be verified");
const visible = minimizeRepeatedNotice(updated);
current.set(key, visible);
return visible;
}
return Object.freeze({enabled: () => enabled(client), load, control});
}

View File

@ -0,0 +1,28 @@
/** Same-origin backend contracts required by the HUX-10 frontend. */
export const API_VERSION = "hux.v1";
export const ACCEPT = "application/vnd.hermes.hux+json; version=1";
export const SNAPSHOT_SCHEMA = "hux.privacy_snapshot.v1";
/**
* GET /hux/v1/privacy/snapshot?session_id=&conversation_id=
* POST /hux/v1/privacy/controls
*
* Identity is derived only from the authenticated server session. The server
* MUST bind both opaque ids to that identity and surface; neither an identity
* nor a tenant selector is accepted from browser state or request parameters.
* Responses contain only the current effective retention state and, at most,
* one active hux.privacy_notice.v1. They MUST omit inactive or previous topic
* categories and the source message that caused classification.
*
* Controls accept schema, session_id, conversation_id, expected_revision and
* one enumerated action. A 409 rejects stale state. Enabling no-store disables
* memory writes and makes retention session-only atomically. Forgetting a
* conversation requires an explicit confirmed request and returns a scoped
* tombstone snapshot; the UI never infers consent. Topic changes clear the prior notice server-side
* so polling cannot reiterate sensitive details after the subject changes.
*/
export const BACKEND_ENDPOINTS = Object.freeze({
snapshot: "/privacy/snapshot",
controls: "/privacy/controls",
});

View File

@ -0,0 +1,16 @@
/** Public integration surface for the inert HUX-10 privacy module. */
export {createPrivacyClient, PrivacyContractError} from "./client.ts";
export {BACKEND_ENDPOINTS} from "./contracts.ts";
export {
FOUNDATION_FLAG,
GENERIC_NOTICE,
PRIVACY_FLAG,
normalizeNotice,
normalizeSnapshot,
privacyControls,
privacyEnabled,
withoutNotice,
} from "./model.ts";
export {PrivacyCenter} from "./PrivacyCenter.tsx";
export type * from "./types.ts";

View File

@ -0,0 +1,142 @@
/** Fail-closed privacy.schema.json adapters for the HUX-10 frontend. */
import {
asRecord,
exactKeys,
isSafeText,
isUtc,
normalizeIdentity,
sameIdentity,
} from "./security.ts";
import type {
PrivacyControl,
PrivacyNoticeView,
PrivacyScope,
PrivacySnapshot,
RawPrivacyNotice,
ScopedIdentity,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const PRIVACY_FLAG = "hux.privacy";
export const GENERIC_NOTICE =
"Sensitive-topic protections are active. This context stays in this conversation and is not remembered without your approval.";
const TOPIC_RULES = Object.freeze({
health: {memoryWrite: "ask", decayDays: 30},
finance: {memoryWrite: "ask", decayDays: 30},
legal: {memoryWrite: "ask", decayDays: 30},
relationships: {memoryWrite: "ask", decayDays: 14},
credentials: {memoryWrite: "deny", decayDays: 1},
minors: {memoryWrite: "deny", decayDays: 7},
location: {memoryWrite: "deny", decayDays: 7},
biometric: {memoryWrite: "deny", decayDays: 1},
} as const);
const NOTICE_CONTROLS = new Set<PrivacyControl>([
"forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss",
]);
const RETENTION_SCOPES = new Set(["conversation", "session"]);
const MEMORY_WRITES = new Set(["ask", "deny"]);
const SHARING = new Set(["never", "same_owner_only"]);
function integer(value: unknown, min: number, max: number): value is number {
return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max;
}
export function privacyEnabled(flags?: Iterable<string>): boolean {
if (!flags) return false;
const enabled = new Set(flags);
return enabled.has(FOUNDATION_FLAG) && enabled.has(PRIVACY_FLAG);
}
export function normalizeNotice(
raw: RawPrivacyNotice,
conversationId: string,
expectedMemoryWrite: "ask" | "deny",
expectedDecayDays: number | null,
): PrivacyNoticeView | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "topic", "conversation_id", "text", "controls", "shown_at",
]) || value.schema !== "hux.privacy_notice.v1" ||
!Object.prototype.hasOwnProperty.call(TOPIC_RULES, value.topic as string) ||
value.conversation_id !== conversationId || !isSafeText(value.text, 1, 280) ||
!isUtc(value.shown_at) || !Array.isArray(value.controls) ||
value.controls.length < 1 || value.controls.length > NOTICE_CONTROLS.size ||
value.controls.some((control) => !NOTICE_CONTROLS.has(control as PrivacyControl)) ||
new Set(value.controls).size !== value.controls.length) return null;
const rule = TOPIC_RULES[value.topic as keyof typeof TOPIC_RULES];
if (rule.memoryWrite !== expectedMemoryWrite || rule.decayDays !== expectedDecayDays)
return null;
// Never carry the category or server text into view state. The server record
// is validated, then reduced to one generic sentence and explicit controls.
return {
schema: "hux.privacy_notice.v1", text: GENERIC_NOTICE,
controls: value.controls as PrivacyControl[], shownAt: value.shown_at as string,
};
}
export function normalizeSnapshot(
raw: unknown,
expectedIdentity: ScopedIdentity,
expectedScope: PrivacyScope,
): PrivacySnapshot | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, [
"schema", "api_version", "identity", "session_id", "conversation_id", "revision",
"no_store", "memory_enabled", "retention", "notice",
]) || value.schema !== "hux.privacy_snapshot.v1" || value.api_version !== "hux.v1" ||
value.session_id !== expectedScope.sessionId ||
value.conversation_id !== expectedScope.conversationId ||
!integer(value.revision, 1, Number.MAX_SAFE_INTEGER) ||
typeof value.no_store !== "boolean" || typeof value.memory_enabled !== "boolean")
return null;
const identity = normalizeIdentity(value.identity);
const retention = asRecord(value.retention);
if (!identity || !sameIdentity(identity, expectedIdentity) || !retention ||
!exactKeys(retention, [
"scope", "memory_write", "topic_context_decay_days", "audit_interval_days",
"next_audit_at", "cross_surface_sharing",
]) || !RETENTION_SCOPES.has(retention.scope as string) ||
!MEMORY_WRITES.has(retention.memory_write as string) ||
(retention.topic_context_decay_days !== null &&
!integer(retention.topic_context_decay_days, 1, 365)) ||
!integer(retention.audit_interval_days, 1, 30) || !isUtc(retention.next_audit_at) ||
!SHARING.has(retention.cross_surface_sharing as string)) return null;
const memoryWrite = retention.memory_write as "ask" | "deny";
const decayDays = retention.topic_context_decay_days as number | null;
if (value.no_store ?
retention.scope !== "session" || value.memory_enabled || memoryWrite !== "deny" || decayDays !== null :
retention.scope !== "conversation" || (value.memory_enabled !== (memoryWrite === "ask")))
return null;
const notice = value.notice === null ? null : normalizeNotice(
value.notice as RawPrivacyNotice, expectedScope.conversationId, memoryWrite, decayDays,
);
if (value.notice !== null && !notice) return null;
return {
schema: "hux.privacy_snapshot.v1", identity, scope: expectedScope,
revision: value.revision as number, noStore: value.no_store,
memoryEnabled: value.memory_enabled,
retention: {
scope: retention.scope as "conversation" | "session", memoryWrite,
topicContextDecayDays: decayDays,
auditIntervalDays: retention.audit_interval_days as number,
nextAuditAt: retention.next_audit_at as string,
crossSurfaceSharing: retention.cross_surface_sharing as "never" | "same_owner_only",
},
notice,
};
}
export function withoutNotice(snapshot: PrivacySnapshot): PrivacySnapshot {
return snapshot.notice ? {...snapshot, notice: null} : snapshot;
}
export function privacyControls(snapshot: PrivacySnapshot): PrivacyControl[] {
const controls: PrivacyControl[] = [
snapshot.noStore ? "disable_no_store" : "enable_no_store",
...(snapshot.memoryEnabled ? ["disable_memory_here" as const] : []),
...(snapshot.notice?.controls || []),
];
return [...new Set(controls)];
}

View File

@ -0,0 +1,80 @@
/** Identity, scope, URL and primitive guards for HUX-10. */
import type {
HuxSurface,
PrivacyScope,
RawIdentity,
ScopedIdentity,
} from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/;
const SURFACES = new Set<HuxSurface>(["chat", "worker", "telegram", "voice", "api"]);
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ?
value as Record<string, unknown> : null;
}
export function exactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const allowed = new Set([...required, ...optional]);
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key));
}
export function isId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) &&
(!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) &&
!Number.isNaN(Date.parse(value));
}
export function isSafeText(value: unknown, min: number, max: number): value is string {
return typeof value === "string" && value.length >= min && value.length <= max &&
!CONTROL.test(value);
}
export function normalizeIdentity(raw: unknown): ScopedIdentity | null {
const value = raw as RawIdentity | null;
if (!value || typeof value !== "object" || Array.isArray(value) ||
!exactKeys(value as Record<string, unknown>, ["tenant_ref", "user_ref", "surface"]))
return null;
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) ||
typeof value.surface !== "string" || !SURFACES.has(value.surface as HuxSurface))
return null;
return {tenantRef: value.tenant_ref, userRef: value.user_ref,
surface: value.surface as HuxSurface};
}
export function sameIdentity(left: ScopedIdentity, right: ScopedIdentity): boolean {
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface;
}
export function normalizeScope(raw: PrivacyScope): PrivacyScope | null {
const value = asRecord(raw);
if (!value || !exactKeys(value, ["sessionId", "conversationId"]) ||
!isId(value.sessionId, "ses") || !isId(value.conversationId, "conv")) return null;
return {sessionId: value.sessionId, conversationId: value.conversationId};
}
export function privacyQuery(scope: PrivacyScope): string {
const safe = normalizeScope(scope);
if (!safe) throw new TypeError("Privacy scope is invalid");
const query = new URLSearchParams({
session_id: safe.sessionId,
conversation_id: safe.conversationId,
});
return `/privacy/snapshot?${query}`;
}

View File

@ -0,0 +1,161 @@
/* Component-scoped HUX-10 presentation; importing it cannot recolor the page. */
.hux-privacy {
color: inherit;
container-type: inline-size;
display: grid;
gap: 0.75rem;
}
.hux-privacy-heading {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.hux-privacy-kicker {
color: color-mix(in srgb, currentColor 58%, #22d3ee);
display: block;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.055em;
text-transform: uppercase;
}
.hux-privacy h2,
.hux-privacy h3 {
line-height: 1.35;
margin: 0;
}
.hux-privacy h2 {
font-size: 0.875rem;
}
.hux-privacy h3 {
font-size: 0.75rem;
}
.hux-privacy-state {
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
font-size: 0.625rem;
font-weight: 650;
padding: 0.25rem 0.5rem;
white-space: nowrap;
}
.hux-privacy-state.is-private {
background: color-mix(in srgb, #22d3ee 10%, transparent);
border-color: color-mix(in srgb, #22d3ee 42%, currentColor);
}
.hux-privacy-notice,
.hux-privacy-confirm {
background: color-mix(in srgb, #22d3ee 5%, transparent);
border: 1px solid color-mix(in srgb, #22d3ee 24%, transparent);
border-radius: 0.7rem;
padding: 0.65rem 0.75rem;
}
.hux-privacy-notice p,
.hux-privacy-confirm span {
color: color-mix(in srgb, currentColor 68%, transparent);
display: block;
font-size: 0.6875rem;
line-height: 1.45;
margin: 0.2rem 0 0.55rem;
}
.hux-privacy-retention {
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
}
.hux-privacy-retention div {
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
display: grid;
gap: 0.15rem;
padding: 0.3rem 0;
}
.hux-privacy-retention dt {
color: color-mix(in srgb, currentColor 58%, transparent);
font-size: 0.625rem;
}
.hux-privacy-retention dd {
font-size: 0.6875rem;
margin: 0;
}
.hux-privacy-controls,
.hux-privacy-confirm div {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.hux-privacy button {
background: transparent;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.55rem;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.6875rem;
min-height: 2rem;
padding: 0.35rem 0.6rem;
}
.hux-privacy button:focus-visible {
outline: 2px solid #22d3ee;
outline-offset: 2px;
}
.hux-privacy button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.hux-privacy .hux-privacy-danger {
border-color: color-mix(in srgb, #fb7185 48%, currentColor);
color: color-mix(in srgb, currentColor 88%, #fb7185);
}
.hux-privacy-confirm {
border-color: color-mix(in srgb, #fb7185 28%, transparent);
}
.hux-privacy-confirm strong {
display: block;
font-size: 0.75rem;
}
.hux-privacy-status,
.hux-privacy-error {
font-size: 0.625rem;
margin: 0;
}
.hux-privacy-status:empty {
display: none;
}
.hux-privacy-error {
color: color-mix(in srgb, currentColor 82%, #fb7185);
}
@container (max-width: 34rem) {
.hux-privacy-retention {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-privacy * {
scroll-behavior: auto;
}
}

View File

@ -0,0 +1,83 @@
/** Scoped types for the default-off HUX-10 privacy frontend. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type PrivacyControl =
| "enable_no_store"
| "disable_no_store"
| "forget_this_conversation"
| "switch_to_private"
| "disable_memory_here"
| "dismiss";
export interface ScopedIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface RawIdentity {
tenant_ref?: unknown;
user_ref?: unknown;
surface?: unknown;
}
export interface PrivacyScope {
sessionId: string;
conversationId: string;
}
export interface RawPrivacyNotice {
schema?: unknown;
topic?: unknown;
conversation_id?: unknown;
text?: unknown;
controls?: unknown;
shown_at?: unknown;
}
/** Intentionally omits topic: the surface never needs to retain that label. */
export interface PrivacyNoticeView {
schema: "hux.privacy_notice.v1";
text: string;
controls: PrivacyControl[];
shownAt: string;
}
export interface RetentionView {
scope: "conversation" | "session";
memoryWrite: "ask" | "deny";
topicContextDecayDays: number | null;
auditIntervalDays: number;
nextAuditAt: string;
crossSurfaceSharing: "never" | "same_owner_only";
}
export interface PrivacySnapshot {
schema: "hux.privacy_snapshot.v1";
identity: ScopedIdentity;
scope: PrivacyScope;
revision: number;
noStore: boolean;
memoryEnabled: boolean;
retention: RetentionView;
notice: PrivacyNoticeView | null;
}
export interface HuxFoundationClient {
apiVersion: string;
identity: ScopedIdentity;
enabled(flag: string): boolean;
endpoint(path: string): string;
}
export interface FetchResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
export interface PrivacyClient {
enabled(): boolean;
load(scope: PrivacyScope): Promise<PrivacySnapshot>;
control(snapshot: PrivacySnapshot, action: PrivacyControl): Promise<PrivacySnapshot>;
}

View File

@ -0,0 +1,135 @@
/** Accessible research UX. All supporting text renders as text, never executable markup. */
import { AlertCircle, BookOpen, CheckCircle2, ExternalLink, FileSearch, HelpCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { normalizeResearchPage, researchEnabled } from "./model.ts";
import type {
CitationView, NotebookView, PassageView, ResearchPage, ResearchWorkspaceProps, SourceView,
} from "./types.ts";
const SUPPORT_LABEL = {
supports: "Supports claim",
partially_supports: "Partially supports",
contradicts: "Contradicts claim",
unverified: "Unverified",
} as const;
function SourceMetadata({source}: {source: SourceView}) {
return <article className="hux-research-source" data-classification={source.classification}>
<header><span>{source.classification} source</span><h4>{source.title}</h4></header>
<dl>
<div><dt>Publisher</dt><dd>{source.publisher || "Not provided"}</dd></div>
<div><dt>Published</dt><dd>{source.publishedAt ?
<time dateTime={source.publishedAt}>{new Date(source.publishedAt).toLocaleDateString()}</time> : "Not provided"}</dd></div>
<div><dt>Retrieved</dt><dd><time dateTime={source.retrievedAt}>
{new Date(source.retrievedAt).toLocaleString()}</time></dd></div>
<div><dt>Kind</dt><dd>{source.kind.replace("_", " ")}</dd></div>
{source.provenance && <div><dt>Collected by</dt><dd>
{source.provenance.actorType} on {source.provenance.surface}
</dd></div>}
</dl>
{source.uri && <a href={source.uri} target="_blank" rel="noopener noreferrer">
Open source <ExternalLink aria-hidden />
</a>}
</article>;
}
function Passage({passage, source}: {passage: PassageView; source: SourceView}) {
return <figure className="hux-research-passage">
<blockquote>{passage.text}</blockquote>
<figcaption>{source.title}{passage.locator ? ` · ${passage.locator}` : ""}</figcaption>
</figure>;
}
function CitationCard({citation, page}: {citation: CitationView; page: ResearchPage}) {
const [expanded, setExpanded] = useState(false);
const passages = citation.passageIds.map((id) => page.passages.find((item) => item.id === id))
.filter((item): item is PassageView => Boolean(item));
const sourceIds = [...new Set(passages.map((item) => item.sourceId))];
const sources = sourceIds.map((id) => page.sources.find((item) => item.id === id))
.filter((item): item is SourceView => Boolean(item));
const panelId = `hux-citation-${citation.id}`;
const Icon = citation.support === "supports" ? CheckCircle2 :
citation.support === "unverified" ? HelpCircle : AlertCircle;
return <li className="hux-research-citation" data-support={citation.support}>
<button type="button" aria-expanded={expanded} aria-controls={panelId}
onClick={() => setExpanded((value) => !value)}>
<Icon aria-hidden /><span><strong>{citation.claim}</strong>
<small>{SUPPORT_LABEL[citation.support]} · {sources.length} source{sources.length === 1 ? "" : "s"}</small></span>
</button>
{expanded && <section id={panelId} aria-label={`Evidence for ${citation.claim}`}>
{citation.note && <p><strong>Research note:</strong> {citation.note}</p>}
<div className="hux-research-passages">{passages.map((item) => {
const source = page.sources.find((candidate) => candidate.id === item.sourceId)!;
return <Passage key={item.id} passage={item} source={source} />;
})}</div>
<div className="hux-research-sources">{sources.map((item) =>
<SourceMetadata key={item.id} source={item} />)}</div>
</section>}
</li>;
}
function CitationStrip({page, messageId}: {page: ResearchPage; messageId?: string}) {
const citations = messageId ? page.citations.filter((item) => item.messageId === messageId) : page.citations;
return <section className="hux-research-strip" aria-labelledby="hux-citation-strip-title">
<header><h2 id="hux-citation-strip-title"><FileSearch aria-hidden /> Sources and citations</h2>
<p>{citations.length} claim{citations.length === 1 ? "" : "s"} with retained evidence.</p></header>
<ul>{citations.map((citation) => <CitationCard key={citation.id} citation={citation} page={page} />)}</ul>
{!citations.length && <p>No citations are attached to this answer.</p>}
</section>;
}
function ItemList({title, values, empty}: {title: string; values: readonly string[]; empty: string}) {
return <section><h4>{title}</h4>{values.length ? <ul>{values.map((value, index) =>
<li key={`${index}-${value}`}>{value}</li>)}</ul> : <p>{empty}</p>}</section>;
}
function Notebook({notebook, page}: {notebook: NotebookView; page: ResearchPage}) {
const sources = notebook.sourceIds.map((id) => page.sources.find((item) => item.id === id))
.filter((item): item is SourceView => Boolean(item));
return <article className="hux-research-notebook">
<header><div><span>{notebook.status}</span><h3>{notebook.question}</h3></div>
<time dateTime={notebook.updatedAt}>Updated {new Date(notebook.updatedAt).toLocaleString()}</time></header>
<section className="hux-research-notebook-grid">
<section><h4>Collected sources</h4>{sources.length ? <ol>{sources.map((source) => <li key={source.id}>
<strong>{source.title}</strong><span>{source.classification} · {source.publisher || source.kind}</span>
</li>)}</ol> : <p>No sources collected.</p>}</section>
<ItemList title="Research notes" values={notebook.notes} empty="No notes recorded." />
<ItemList title="Assumptions" values={notebook.assumptions} empty="No assumptions recorded." />
<ItemList title="Unresolved questions" values={notebook.unresolvedQuestions} empty="No unresolved questions." />
</section>
</article>;
}
/** Render nothing unless foundation, friendly modes, and research are negotiated. */
export function ResearchWorkspace(props: ResearchWorkspaceProps) {
const enabled = researchEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeResearchPage(props.page, props.identity, props.scope) : null,
[enabled, props.identity, props.page, props.scope]);
const [selectedId, setSelectedId] = useState<string | null>(null);
if (!enabled || !page) return null;
const selected = page.notebooks.find((item) => item.id === selectedId) || page.notebooks[0] || null;
return <section className="hux-research" aria-label="Research and citations" aria-busy={props.busy}>
{props.error && <p className="hux-research-error" role="alert">{props.error}</p>}
{page.rejected > 0 && <p className="hux-research-warning" role="status">
Some evidence was withheld because its scope, provenance, URL, or references were invalid.
</p>}
<CitationStrip page={page} messageId={props.messageId} />
<section className="hux-research-view" aria-labelledby="hux-research-view-title">
<header><h2 id="hux-research-view-title"><BookOpen aria-hidden /> Research notebooks</h2>
<p>Sources, notes, assumptions, and open questions stay together.</p></header>
<div className="hux-research-layout">
<nav aria-label="Research notebooks"><ul>{page.notebooks.map((item) => <li key={item.id}>
<button type="button" aria-current={selected?.id === item.id ? "page" : undefined}
onClick={() => setSelectedId(item.id)}>{item.question}<small>{item.status}</small></button>
</li>)}</ul>{!page.notebooks.length && <p>No research notebooks yet.</p>}</nav>
<main>{selected ? <Notebook key={selected.id} notebook={selected} page={page} /> :
<p>Select a notebook to inspect retained research.</p>}</main>
</div>
</section>
<p className="sr-only" aria-live="polite" role="status">
{`${page.citations.length} citations and ${page.notebooks.length} notebooks available`}
</p>
</section>;
}

View File

@ -0,0 +1,46 @@
/** Same-origin endpoint descriptors for the future HUX-08 backend. */
import { researchEnabled } from "./model.ts";
import { normalizeIdentity, normalizeScope, safeMessageId, safePathId, sameIdentity, sameScope } from "./security.ts";
import type { HuxIdentity, ResearchEndpoint, ResearchEndpointContract, ResearchScope } from "./types.ts";
interface FoundationClient {
apiVersion: string;
identity: HuxIdentity;
endpoint(path: string): string;
}
function endpoint(method: ResearchEndpoint["method"], path: string, responseSchema: string,
requiredBody?: readonly string[]): ResearchEndpoint {
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
}
/** Describes authenticated endpoints without issuing network requests. */
export function createResearchEndpointContract(client: FoundationClient, scope: ResearchScope,
flags?: Iterable<string>): ResearchEndpointContract | null {
if (!researchEnabled(flags)) return null;
const rawIdentity = {tenant_ref: client.identity.tenantRef, user_ref: client.identity.userRef,
surface: client.identity.surface};
const rawScope = {project_id: scope.projectId, conversation_id: scope.conversationId};
if (client.apiVersion !== "hux.v1" || !sameIdentity(normalizeIdentity(rawIdentity), client.identity) ||
!sameScope(normalizeScope(rawScope), scope)) throw new TypeError("Scoped HUX v1 research client required");
const base = client.endpoint(`/projects/${safePathId(scope.projectId, "prj")}` +
`/conversations/${safePathId(scope.conversationId, "conv")}/research`);
return Object.freeze({
page: endpoint("GET", base, "hux.research_page.v1"),
citationsForMessage(messageId) {
return endpoint("GET", `${base}/messages/${safeMessageId(messageId)}/citations`, "hux.citation_page.v1");
},
notebook(notebookId) {
return endpoint("GET", `${base}/notebooks/${safePathId(notebookId, "nb")}`, "hux.research_notebook.v1");
},
updateNotebook(notebookId) {
return endpoint("PUT", `${base}/notebooks/${safePathId(notebookId, "nb")}`, "hux.research_notebook.v1",
["expected_updated_at", "status", "assumptions", "unresolved_questions"]);
},
attachCitation(messageId) {
return endpoint("POST", `${base}/messages/${safeMessageId(messageId)}/citations`, "hux.citation.v1",
["claim", "passage_ids", "support", "note"]);
},
});
}

View File

@ -0,0 +1,4 @@
export { ResearchWorkspace } from "./ResearchWorkspace.tsx";
export { createResearchEndpointContract } from "./endpoints.ts";
export { normalizeResearchPage, researchEnabled } from "./model.ts";
export type * from "./types.ts";

View File

@ -0,0 +1,150 @@
/** Schema-aligned, fail-closed HUX-08 research normalization. */
import {
asRecord, boundedText, isHash, isId, isUtc, normalizeIdentity, normalizeScope,
safeSourceUri, sameIdentity, sameScope,
} from "./security.ts";
import type {
CitationView, Classification, HuxIdentity, NotebookStatus, NotebookView, PassageView,
ProvenanceView, RawResearchEnvelope, ResearchPage, ResearchScope, SourceKind, SourceView, Support,
} from "./types.ts";
export const FOUNDATION_FLAG = "hux.foundation";
export const FRIENDLY_MODES_FLAG = "hux.friendly_modes";
export const RESEARCH_FLAG = "hux.research";
const KINDS = new Set<SourceKind>(["web", "document", "artifact", "memory", "tool_output", "dataset"]);
const CLASSIFICATIONS = new Set<Classification>(["primary", "secondary", "unknown"]);
const SUPPORT = new Set<Support>(["supports", "partially_supports", "contradicts", "unverified"]);
const STATUSES = new Set<NotebookStatus>(["open", "answered", "abandoned"]);
const MAX_RECORDS = 500;
export function researchEnabled(flags?: Iterable<string>): boolean {
const active = new Set(flags || []);
return [FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, RESEARCH_FLAG].every((flag) => active.has(flag));
}
function provenance(raw: unknown, conversationId: string): ProvenanceView | null | false {
if (raw === undefined) return null;
const value = asRecord(raw);
const actor = asRecord(value?.actor);
if (!value || !actor || !["chat", "worker", "telegram", "voice", "api"].includes(String(value.surface)) ||
!["user", "assistant", "tool", "system", "operator"].includes(String(actor.type)) ||
!boundedText(actor.id, 1, 120) || !isUtc(value.recorded_at) ||
(value.conversation_id !== undefined && value.conversation_id !== conversationId) ||
(value.run_id !== undefined && !boundedText(value.run_id, 1, 120))) return false;
return {surface: value.surface as ProvenanceView["surface"], actorType: actor.type as ProvenanceView["actorType"],
recordedAt: value.recorded_at, runId: value.run_id as string | undefined || null};
}
function source(raw: unknown, conversationId: string): SourceView | null {
const value = asRecord(raw);
if (!value || value.schema !== "hux.source.v1" || !isId(value.id, "src") ||
!KINDS.has(value.kind as SourceKind) || !boundedText(value.title, 1, 300) ||
!boundedText(value.publisher ?? "", 0, 200) || !isUtc(value.retrieved_at) ||
!CLASSIFICATIONS.has(value.classification as Classification) ||
(value.published_at !== undefined && !isUtc(value.published_at)) ||
(isUtc(value.published_at) && value.published_at > value.retrieved_at) ||
(value.content_hash !== undefined && !isHash(value.content_hash))) return null;
const uri = safeSourceUri(value.uri);
if (value.uri !== undefined && value.uri !== "" && !uri) return null;
const origin = provenance(value.provenance, conversationId);
if (origin === false) return null;
return {id: value.id, kind: value.kind as SourceKind, title: value.title,
publisher: value.publisher as string | undefined || "", publishedAt: value.published_at as string | undefined || null,
retrievedAt: value.retrieved_at, classification: value.classification as Classification,
uri, provenance: origin};
}
function locator(raw: unknown): string | null {
if (raw === undefined) return "";
const value = asRecord(raw);
if (!value || Object.keys(value).some((key) => !["page", "selector", "line_start", "line_end", "char_start", "char_end"].includes(key))) return null;
const positive = (item: unknown) => Number.isSafeInteger(item) && (item as number) >= 1;
const natural = (item: unknown) => Number.isSafeInteger(item) && (item as number) >= 0;
if ((value.page !== undefined && !positive(value.page)) ||
(value.selector !== undefined && !boundedText(value.selector, 0, 500)) ||
(value.line_start !== undefined && !positive(value.line_start)) ||
(value.line_end !== undefined && !positive(value.line_end)) ||
(value.char_start !== undefined && !natural(value.char_start)) ||
(value.char_end !== undefined && !natural(value.char_end)) ||
(positive(value.line_start) && positive(value.line_end) && (value.line_end as number) < (value.line_start as number)) ||
(natural(value.char_start) && natural(value.char_end) && (value.char_end as number) < (value.char_start as number))) return null;
const parts = [value.page && `page ${value.page}`, value.selector && String(value.selector),
value.line_start && `lines ${value.line_start}${value.line_end ? `–${value.line_end}` : ""}`,
value.char_start !== undefined && `characters ${value.char_start}${value.char_end !== undefined ? `–${value.char_end}` : ""}`];
return parts.filter(Boolean).join(" · ");
}
function passage(raw: unknown, sources: ReadonlySet<string>): PassageView | null {
const value = asRecord(raw);
if (!value || value.schema !== "hux.passage.v1" || !isId(value.id, "psg") ||
!isId(value.source_id, "src") || !sources.has(value.source_id) ||
!boundedText(value.text, 1, 4_000) || !isHash(value.hash)) return null;
const position = locator(value.locator);
return position === null ? null : {id: value.id, sourceId: value.source_id, text: value.text, locator: position};
}
function citation(raw: unknown, passages: ReadonlySet<string>): CitationView | null {
const value = asRecord(raw);
if (!value || value.schema !== "hux.citation.v1" || !isId(value.id, "cit") ||
!boundedText(value.message_id, 1, 120) || !boundedText(value.claim, 1, 1_000) ||
!Array.isArray(value.passage_ids) || value.passage_ids.length < 1 || value.passage_ids.length > 32 ||
new Set(value.passage_ids).size !== value.passage_ids.length ||
value.passage_ids.some((id) => !isId(id, "psg") || !passages.has(id)) ||
!SUPPORT.has(value.support as Support) || !boundedText(value.note ?? "", 0, 500)) return null;
return {id: value.id, messageId: value.message_id, claim: value.claim,
passageIds: [...value.passage_ids] as string[], support: value.support as Support,
note: value.note as string | undefined || ""};
}
function idList(raw: unknown, prefix: string, allowed: ReadonlySet<string>): string[] | null {
return Array.isArray(raw) && raw.length <= MAX_RECORDS && new Set(raw).size === raw.length &&
raw.every((id) => isId(id, prefix) && allowed.has(id)) ? [...raw] as string[] : null;
}
function textList(raw: unknown): string[] | null {
return Array.isArray(raw) && raw.length <= 64 && raw.every((item) => boundedText(item, 1, 500)) ? [...raw] : null;
}
function notebook(raw: unknown, scope: ResearchScope, sources: ReadonlySet<string>,
passages: ReadonlySet<string>, citations: ReadonlyMap<string, CitationView>): NotebookView | null {
const value = asRecord(raw);
if (!value || value.schema !== "hux.research_notebook.v1" || !isId(value.id, "nb") ||
value.conversation_id !== scope.conversationId || !boundedText(value.question, 1, 1_000) ||
!STATUSES.has(value.status as NotebookStatus) || !isUtc(value.updated_at)) return null;
const sourceIds = idList(value.source_ids, "src", sources);
const passageIds = idList(value.passage_ids, "psg", passages);
const citationIds = idList(value.citation_ids, "cit", new Set(citations.keys()));
const assumptions = textList(value.assumptions);
const unresolved = textList(value.unresolved_questions);
if (!sourceIds || !passageIds || !citationIds || !assumptions || !unresolved) return null;
const notes = citationIds.map((id) => citations.get(id)?.note || "").filter(Boolean);
return {id: value.id, question: value.question, status: value.status as NotebookStatus,
sourceIds, passageIds, citationIds, assumptions, unresolvedQuestions: unresolved,
notes, updatedAt: value.updated_at};
}
function unique<T extends {id: string}>(values: Array<T | null>): {items: T[]; rejected: number} {
const seen = new Set<string>();
const items = values.filter((item): item is T => Boolean(item && !seen.has(item.id) && seen.add(item.id)));
return {items, rejected: values.length - items.length};
}
export function normalizeResearchPage(raw: RawResearchEnvelope, expected: HuxIdentity,
scope: ResearchScope): ResearchPage | null {
if (!raw || typeof raw !== "object" || raw.schema !== "hux.research_page.v1" || raw.api_version !== "hux.v1" ||
!sameIdentity(normalizeIdentity(raw.identity), expected) || !sameScope(normalizeScope(raw.binding), scope) ||
!Array.isArray(raw.sources) || raw.sources.length > MAX_RECORDS || !Array.isArray(raw.passages) ||
raw.passages.length > MAX_RECORDS || !Array.isArray(raw.citations) || raw.citations.length > MAX_RECORDS ||
!Array.isArray(raw.notebooks) || raw.notebooks.length > 100) return null;
const sourceResult = unique(raw.sources.map((item) => source(item, scope.conversationId)));
const sourceIds = new Set(sourceResult.items.map((item) => item.id));
const passageResult = unique(raw.passages.map((item) => passage(item, sourceIds)));
const passageIds = new Set(passageResult.items.map((item) => item.id));
const citationResult = unique(raw.citations.map((item) => citation(item, passageIds)));
const citationMap = new Map(citationResult.items.map((item) => [item.id, item]));
const notebookResult = unique(raw.notebooks.map((item) => notebook(item, scope, sourceIds, passageIds, citationMap)));
return {sources: sourceResult.items, passages: passageResult.items, citations: citationResult.items,
notebooks: notebookResult.items, rejected: sourceResult.rejected + passageResult.rejected +
citationResult.rejected + notebookResult.rejected};
}

View File

@ -0,0 +1,76 @@
/** Identity, scope, URL, text, and record primitives for HUX-08. */
import type { HuxIdentity, HuxSurface, ResearchScope } from "./types.ts";
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const HASH = /^sha256:[0-9a-f]{64}$/;
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
const SURFACES = new Set<HuxSurface>(["chat", "worker", "telegram", "voice", "api"]);
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function isId(value: unknown, prefix?: string): value is string {
return typeof value === "string" && ID.test(value) && (!prefix || value.startsWith(`${prefix}_`));
}
export function isUtc(value: unknown): value is string {
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
}
export function isHash(value: unknown): value is string {
return typeof value === "string" && HASH.test(value);
}
export function boundedText(value: unknown, min: number, max: number): value is string {
return typeof value === "string" && value.length >= min && value.length <= max && !CONTROL.test(value);
}
export function normalizeIdentity(raw: unknown): HuxIdentity | null {
const value = asRecord(raw);
if (!value || typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) || !SURFACES.has(value.surface as HuxSurface)) return null;
return {tenantRef: value.tenant_ref, userRef: value.user_ref, surface: value.surface as HuxSurface};
}
export function normalizeScope(raw: unknown): ResearchScope | null {
const value = asRecord(raw);
return value && isId(value.project_id, "prj") && isId(value.conversation_id, "conv") ?
{projectId: value.project_id, conversationId: value.conversation_id} : null;
}
export function sameIdentity(left: HuxIdentity | null, right: HuxIdentity): boolean {
return Boolean(left && left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
left.surface === right.surface);
}
export function sameScope(left: ResearchScope | null, right: ResearchScope): boolean {
return Boolean(left && left.projectId === right.projectId && left.conversationId === right.conversationId);
}
/** Allow HTTPS sources and same-origin HUX paths only. */
export function safeSourceUri(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string" || value.length > 2_000 || CONTROL.test(value)) return null;
if (/^\/hux\/v1\/(?!.*(?:\.\.|[?#\\]))[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/.test(value)) return value;
try {
const parsed = new URL(value);
return parsed.protocol === "https:" && !parsed.username && !parsed.password ? parsed.href : null;
} catch {
return null;
}
}
export function safeMessageId(value: unknown): string {
if (!boundedText(value, 1, 120) || /[/\\?#]/.test(value)) throw new TypeError("Safe message id required");
return encodeURIComponent(value);
}
export function safePathId(value: unknown, prefix: string): string {
if (!isId(value, prefix)) throw new TypeError(`Opaque ${prefix} id required`);
return encodeURIComponent(value);
}

View File

@ -0,0 +1,154 @@
.hux-research {
--research-border: color-mix(in srgb, currentColor 16%, transparent);
display: grid;
gap: 1.25rem;
min-width: 0;
}
.hux-research h2,
.hux-research h3,
.hux-research h4,
.hux-research p {
margin-block: .25rem;
}
.hux-research-strip,
.hux-research-view {
border: 1px solid var(--research-border);
border-radius: .8rem;
padding: 1rem;
}
.hux-research-strip > header,
.hux-research-view > header,
.hux-research-notebook > header {
align-items: start;
display: flex;
justify-content: space-between;
}
.hux-research-strip ul,
.hux-research-layout ul,
.hux-research-notebook ol {
list-style: none;
margin: 0;
padding: 0;
}
.hux-research-citation {
border-top: 1px solid var(--research-border);
}
.hux-research-citation > button {
align-items: start;
display: flex;
gap: .75rem;
padding-block: .85rem;
text-align: start;
width: 100%;
}
.hux-research-citation > button span,
.hux-research-source header,
.hux-research-layout nav button {
display: grid;
gap: .2rem;
}
.hux-research-citation[data-support="contradicts"],
.hux-research-citation[data-support="unverified"] {
border-inline-start: .2rem solid currentColor;
padding-inline-start: .7rem;
}
.hux-research-passages,
.hux-research-sources,
.hux-research-notebook-grid {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}
.hux-research-passage,
.hux-research-source,
.hux-research-notebook-grid > section {
border: 1px solid var(--research-border);
border-radius: .55rem;
margin: 0;
overflow-wrap: anywhere;
padding: .8rem;
}
.hux-research-passage blockquote {
margin: 0 0 .5rem;
white-space: pre-wrap;
}
.hux-research-source dl {
display: grid;
gap: .35rem;
}
.hux-research-source dl div {
display: grid;
grid-template-columns: 6rem minmax(0, 1fr);
}
.hux-research-source dt {
font-weight: 600;
}
.hux-research-layout {
display: grid;
gap: 1rem;
grid-template-columns: minmax(12rem, 20rem) minmax(0, 1fr);
margin-top: 1rem;
}
.hux-research-layout nav {
border-inline-end: 1px solid var(--research-border);
padding-inline-end: 1rem;
}
.hux-research-layout nav button {
padding: .65rem;
text-align: start;
width: 100%;
}
.hux-research-layout nav button[aria-current="page"] {
outline: 2px solid currentColor;
outline-offset: -2px;
}
.hux-research-error,
.hux-research-warning {
border-inline-start: .25rem solid currentColor;
padding: .65rem .85rem;
}
@media (max-width: 48rem) {
.hux-research-layout {
grid-template-columns: 1fr;
}
.hux-research-layout nav {
border-block-end: 1px solid var(--research-border);
border-inline-end: 0;
padding-block-end: 1rem;
padding-inline-end: 0;
}
.hux-research-notebook > header {
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
.hux-research *,
.hux-research *::before,
.hux-research *::after {
scroll-behavior: auto !important;
transition-duration: .01ms !important;
}
}

View File

@ -0,0 +1,110 @@
/** Safe frontend contracts for default-off HUX-08 research and citations. */
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
export type SourceKind = "web" | "document" | "artifact" | "memory" | "tool_output" | "dataset";
export type Classification = "primary" | "secondary" | "unknown";
export type Support = "supports" | "partially_supports" | "contradicts" | "unverified";
export type NotebookStatus = "open" | "answered" | "abandoned";
export interface HuxIdentity {
tenantRef: string;
userRef: string;
surface: HuxSurface;
}
export interface ResearchScope {
projectId: string;
conversationId: string;
}
export interface ProvenanceView {
surface: HuxSurface;
actorType: "user" | "assistant" | "tool" | "system" | "operator";
recordedAt: string;
runId: string | null;
}
export interface SourceView {
id: string;
kind: SourceKind;
title: string;
publisher: string;
publishedAt: string | null;
retrievedAt: string;
classification: Classification;
uri: string | null;
provenance: ProvenanceView | null;
}
export interface PassageView {
id: string;
sourceId: string;
text: string;
locator: string;
}
export interface CitationView {
id: string;
messageId: string;
claim: string;
passageIds: string[];
support: Support;
note: string;
}
export interface NotebookView {
id: string;
question: string;
status: NotebookStatus;
sourceIds: string[];
passageIds: string[];
citationIds: string[];
assumptions: string[];
unresolvedQuestions: string[];
notes: string[];
updatedAt: string;
}
export interface ResearchPage {
sources: SourceView[];
passages: PassageView[];
citations: CitationView[];
notebooks: NotebookView[];
rejected: number;
}
export interface RawResearchEnvelope {
schema?: unknown;
api_version?: unknown;
identity?: unknown;
binding?: unknown;
sources?: unknown;
passages?: unknown;
citations?: unknown;
notebooks?: unknown;
}
export interface ResearchEndpoint {
method: "GET" | "PUT" | "POST";
path: string;
responseSchema: string;
requiredBody?: readonly string[];
}
export interface ResearchEndpointContract {
page: ResearchEndpoint;
citationsForMessage(messageId: string): ResearchEndpoint;
notebook(notebookId: string): ResearchEndpoint;
updateNotebook(notebookId: string): ResearchEndpoint;
attachCitation(messageId: string): ResearchEndpoint;
}
export interface ResearchWorkspaceProps {
flags?: Iterable<string>;
identity: HuxIdentity;
scope: ResearchScope;
page: RawResearchEnvelope;
messageId?: string;
busy?: boolean;
error?: string | null;
}

View File

@ -0,0 +1,412 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
ACTIVITY_TIMELINE_FLAG,
FOUNDATION_FLAG,
activityTimelineEnabled,
mergeActivityEvents,
normalizeCancellationReceipt,
surfacePolicy,
} from "../../dockerfiles/hermes-webui-hux/activity/model.ts";
import {
isOpaqueId,
isRfc3339Utc,
safeEvidenceLabel,
safeText,
} from "../../dockerfiles/hermes-webui-hux/activity/security.ts";
const CONVERSATION = "conv_test1234";
const KINDS = JSON.parse(
readFileSync("services/hermes/contracts/hux/event.schema.json"),
).properties.kind.enum;
function event(seq, kind = "tool.call", extra = {}) {
return {
schema: "hux.event.v1",
id: `evt_test${String(seq).padStart(4, "0")}`,
seq,
ts: `2026-08-24T10:${String(Math.floor(seq / 60)).padStart(2, "0")}:${String(seq % 60).padStart(2, "0")}Z`,
conversation_id: CONVERSATION,
kind,
summary: `Safe update ${seq}`,
evidence: [],
provenance: { surface: "chat" },
sensitivity: "personal",
redaction: { level: "none" },
...extra,
};
}
test("feature remains default-off and depends on the foundation", () => {
assert.equal(activityTimelineEnabled(), false);
assert.equal(activityTimelineEnabled([]), false);
assert.equal(activityTimelineEnabled([ACTIVITY_TIMELINE_FLAG]), false);
assert.equal(activityTimelineEnabled([FOUNDATION_FLAG]), false);
assert.equal(
activityTimelineEnabled([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG]),
true,
);
});
test("Chat policy is intentionally calmer and every returned policy is isolated", () => {
const chat = surfacePolicy("chat");
const worker = surfacePolicy("worker");
assert.ok(chat.maxItems < worker.maxItems);
assert.ok(chat.initialItems < worker.initialItems);
assert.ok(chat.evidenceLimit < worker.evidenceLimit);
assert.equal(chat.expandRoutineEvents, false);
assert.equal(worker.expandRoutineEvents, true);
chat.maxItems = 1;
assert.equal(surfacePolicy("chat").maxItems, 80);
assert.deepEqual(surfacePolicy("api"), worker);
assert.ok(
surfacePolicy("voice").maxItems < surfacePolicy("telegram").maxItems,
);
});
test("plain-text security redacts credential shapes without serializing objects", () => {
assert.equal(safeText({ token: "nope" }, "fallback"), "fallback");
assert.equal(safeText(" \n\t ", "fallback"), "fallback");
assert.equal(safeText("abc\u0000def", "fallback"), "abc def");
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
assert.equal(safeText("abcdef", "fallback", 0), "…");
const unsafe = [
"password=hunter2",
"Authorization: Basic-abcd",
"Bearer abc.DEF_123",
"eyJabcdefgh.abcdefghijkl.abcdefghijkl",
"https://user:pass@example.test/path",
"Abcdefghijklmnopqrstuvwxyz1234567890ABCD",
"-----BEGIN PRIVATE KEY----- top secret -----END PRIVATE KEY-----",
].join(" ");
const scrubbed = safeText(unsafe, "fallback", 1000);
assert.doesNotMatch(
scrubbed,
/hunter2|Basic-abcd|abc\.DEF_123|user:pass|top secret|1234567890ABCD/,
);
assert.match(scrubbed, /redacted/);
});
test("only allow-listed evidence labels cross the display boundary", () => {
assert.deepEqual(safeEvidenceLabel("tool_result"), {
kind: "tool_result",
label: "Tool result",
});
assert.deepEqual(safeEvidenceLabel("url"), { kind: "url", label: "Website" });
assert.equal(safeEvidenceLabel("raw_tool_arguments"), null);
assert.equal(safeEvidenceLabel(7), null);
assert.equal(isOpaqueId("evt_abcd", "evt"), true);
assert.equal(isOpaqueId("evt_abcd"), true);
assert.equal(isOpaqueId("mem_abcd", "evt"), false);
assert.equal(isOpaqueId("not opaque"), false);
assert.equal(isOpaqueId(7), false);
assert.equal(isRfc3339Utc("2026-08-24T10:00:00.123Z"), true);
assert.equal(isRfc3339Utc("today"), false);
assert.equal(isRfc3339Utc("2026-99-99T10:00:00Z"), false);
assert.equal(isRfc3339Utc(7), false);
});
test("every contract event maps to a compact semantic phase", () => {
const records = KINDS.map((kind, seq) =>
event(seq, kind, {
parent_event_id: kind === "run.started" ? "evt_parent1234" : undefined,
}),
);
const result = mergeActivityEvents([], records, CONVERSATION, "worker");
assert.equal(result.invalid, 0);
assert.equal(result.items.length, KINDS.length);
const phases = new Set(result.items.map((item) => item.phase));
assert.deepEqual(
phases,
new Set([
"intent",
"action",
"evidence",
"delegation",
"decision",
"completion",
"failure",
]),
);
assert.equal(
result.items.find((item) => item.kind === "run.failed").tone,
"warning",
);
assert.equal(
result.items.find((item) => item.kind === "run.completed").tone,
"success",
);
assert.equal(
result.items.find((item) => item.kind === "approval.requested").tone,
"warning",
);
assert.equal(
result.items.find((item) => item.kind === "tool.call").routine,
true,
);
assert.equal(
result.items.find((item) => item.kind === "message.user").routine,
false,
);
});
test("root runs are actions while child runs are explicit delegations", () => {
const result = mergeActivityEvents(
[],
[
event(1, "run.started"),
event(2, "run.started", { parent_event_id: "evt_parent1234" }),
],
CONVERSATION,
"chat",
);
assert.deepEqual(
result.items.map((item) => item.phase),
["action", "delegation"],
);
assert.deepEqual(
result.items.map((item) => item.tone),
["active", "active"],
);
});
test("normalization redacts summaries and never projects detail, IDs, or URIs", () => {
const raw = event(1, "tool.result", {
summary: "token=secret-value result is ready",
run_id: "run-safe",
detail: { arguments: "--token secret", result: "raw secret result" },
evidence: [
{
kind: "tool_result",
id: "secret-id",
uri: "https://user:pass@example.test",
},
{ kind: "file", id: "/secret/path" },
{ kind: "bogus", id: "raw" },
{ kind: "url", id: "https://secret" },
],
});
const item = mergeActivityEvents([], [raw], CONVERSATION, "chat").items[0];
const serialized = JSON.stringify(item);
assert.equal(item.runId, "run-safe");
assert.deepEqual(
item.evidence.map((entry) => entry.label),
["Tool result", "File", "Website"],
);
assert.doesNotMatch(
serialized,
/secret-value|raw secret result|secret-id|secret\/path|user:pass/,
);
assert.equal("detail" in item, false);
const invalidRun = mergeActivityEvents(
[],
[event(2, "tool.call", { run_id: { raw: true } })],
CONVERSATION,
"chat",
);
assert.equal(invalidRun.items[0].runId, undefined);
});
test("full or restricted redaction ignores even an otherwise safe summary", () => {
const result = mergeActivityEvents(
[],
[
event(1, "tool.call", {
summary: "do not show",
redaction: { level: "full" },
}),
event(2, "run.failed", {
summary: "health detail",
sensitivity: "restricted",
}),
event(3, "run.completed", { summary: {}, redaction: null }),
event(4, "citation.attached", {
summary: "",
redaction: { level: "partial" },
}),
],
CONVERSATION,
"chat",
);
assert.deepEqual(
result.items.map((item) => item.summary),
[
"Hermes used a tool",
"The run failed; sensitive detail is hidden",
"The run completed",
"Activity recorded; sensitive detail is hidden",
],
);
});
test("reconnect merge is ordered, idempotent, and fail-closed on conflicts", () => {
const first = mergeActivityEvents(
[],
[event(3), event(1)],
CONVERSATION,
"chat",
);
const replay = { ...event(3), summary: "attempted rewrite" };
const idConflict = { ...event(4), id: first.items[0].id };
const seqConflict = { ...event(1), id: "evt_conflict1" };
const result = mergeActivityEvents(
first.items,
[replay, idConflict, seqConflict, event(2)],
CONVERSATION,
"chat",
);
assert.deepEqual(
result.items.map((item) => item.seq),
[1, 2, 3],
);
assert.equal(result.items.at(-1).summary, "Safe update 3");
assert.equal(result.replays, 1);
assert.equal(result.conflicts, 2);
assert.equal(result.afterSeq, 3);
assert.equal(result.invalid, 0);
});
test("an initial same-sequence conflict resolves deterministically", () => {
const higher = { ...event(7), id: "evt_zulu1234" };
const lower = {
...event(7),
id: "evt_alpha1234",
summary: "Deterministic owner",
};
const forward = mergeActivityEvents(
[],
[higher, lower],
CONVERSATION,
"chat",
);
const reverse = mergeActivityEvents(
[],
[lower, higher],
CONVERSATION,
"chat",
);
assert.deepEqual(forward.items, reverse.items);
assert.equal(forward.items[0].id, "evt_alpha1234");
assert.equal(forward.conflicts, 1);
});
test("malformed and cross-conversation events are rejected", () => {
const bad = [
{ ...event(1), schema: "hux.event.v0" },
{ ...event(2), id: "bad" },
{ ...event(3), seq: -1 },
{ ...event(4), ts: "later" },
{ ...event(5), conversation_id: "conv_other1234" },
{ ...event(6), kind: "tool.raw" },
];
const result = mergeActivityEvents([], bad, CONVERSATION, "chat");
assert.deepEqual(result.items, []);
assert.equal(result.invalid, bad.length);
assert.equal(result.afterSeq, -1);
});
test("retention is bounded per surface while preserving the resume watermark", () => {
const records = Array.from({ length: 85 }, (_, seq) => event(seq));
const result = mergeActivityEvents([], records, CONVERSATION, "chat");
assert.equal(result.items.length, 80);
assert.equal(result.items[0].seq, 5);
assert.equal(result.afterSeq, 84);
assert.equal(result.truncated, 5);
const replayed = mergeActivityEvents(
result.items,
[event(84)],
CONVERSATION,
"chat",
);
assert.equal(replayed.replays, 1);
assert.equal(replayed.truncated, 0);
});
test("evidence display and cancellation receipts obey surface bounds", () => {
const evidence = Array.from({ length: 10 }, (_, index) => ({
kind: index % 2 ? "file" : "source",
id: `secret-${index}`,
uri: `https://token-${index}`,
}));
assert.equal(
mergeActivityEvents(
[],
[event(1, "citation.attached", { evidence })],
CONVERSATION,
"chat",
).items[0].evidence.length,
3,
);
assert.equal(
mergeActivityEvents(
[],
[event(1, "citation.attached", { evidence })],
CONVERSATION,
"worker",
).items[0].evidence.length,
8,
);
const receipt = {
schema: "hux.cancel_receipt.v1",
id: "rcpt_test1234",
run_id: "run_1",
requested_at: "2026-08-24T10:00:00Z",
acknowledged_at: "2026-08-24T10:00:01Z",
completed_at: "not-a-time",
outcome: "cancelled",
side_effects: Array.from({ length: 7 }, (_, index) => ({
description: index === 0 ? "token=do-not-show" : `Effect ${index}`,
reverted: index % 2 === 0,
evidence: { kind: "file", id: "secret" },
})),
};
const safe = normalizeCancellationReceipt(receipt, "chat");
assert.equal(safe.sideEffects.length, 4);
assert.equal(safe.omittedEffects, 3);
assert.equal(safe.completedAt, undefined);
assert.equal(safe.acknowledgedAt, receipt.acknowledged_at);
assert.doesNotMatch(JSON.stringify(safe), /do-not-show|"evidence"|"secret"/);
});
test("cancellation parsing rejects bad identity, time, run, and outcome", () => {
const base = {
schema: "hux.cancel_receipt.v1",
id: "rcpt_test1234",
run_id: "run_1",
requested_at: "2026-08-24T10:00:00Z",
outcome: "already_complete",
side_effects: [{ description: {}, reverted: false }],
};
const safe = normalizeCancellationReceipt(base, "worker");
assert.equal(safe.sideEffects[0].description, "A side effect was recorded");
assert.equal(safe.completedAt, undefined);
assert.equal(
normalizeCancellationReceipt({ ...base, schema: "old" }, "chat"),
null,
);
assert.equal(
normalizeCancellationReceipt({ ...base, id: "bad" }, "chat"),
null,
);
assert.equal(
normalizeCancellationReceipt({ ...base, run_id: 3 }, "chat"),
null,
);
assert.equal(
normalizeCancellationReceipt({ ...base, requested_at: "bad" }, "chat"),
null,
);
assert.equal(
normalizeCancellationReceipt({ ...base, outcome: "unknown" }, "chat"),
null,
);
assert.equal(
normalizeCancellationReceipt({ ...base, side_effects: null }, "chat")
.sideEffects.length,
0,
);
});

View File

@ -0,0 +1,67 @@
"""Executable and static quality gates for the isolated HUX-01 frontend."""
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ACTIVITY = ROOT / "dockerfiles" / "hermes-webui-hux" / "activity"
def test_hux_activity_model_node_suite_and_coverage():
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/activity/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/activity/security.ts",
"testing/tests/test_hermes_hux_ui_activity.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_activity_component_is_flagged_accessible_and_payload_safe():
component = (ACTIVITY / "ActivityTimeline.tsx").read_text(encoding="utf-8")
model = (ACTIVITY / "model.ts").read_text(encoding="utf-8")
styles = (ACTIVITY / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled) return null" in component
assert "activityTimelineEnabled(flags)" in component
assert "FOUNDATION_FLAG" in model and "ACTIVITY_TIMELINE_FLAG" in model
assert 'role="log"' in component and 'aria-live="polite"' in component
assert 'aria-busy={reconnecting}' in component
assert "dangerouslySetInnerHTML" not in component
assert ".detail" not in component
assert "JSON.stringify" not in component
assert "evidence.uri" not in component and "evidence.id" not in component
assert ".hux-activity" in styles
assert "body" not in styles and ":root" not in styles
assert "prefers-reduced-motion" in styles
def test_hux_activity_sources_respect_size_and_scope_boundaries():
sources = sorted(ACTIVITY.glob("*"))
assert {path.name for path in sources} == {
"ActivityTimeline.tsx",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
assert "dockerfiles/hermes-webui-hux/activity" not in (
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
).read_text(encoding="utf-8")

View File

@ -0,0 +1,361 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createArtifactEndpointContract } from "../../dockerfiles/hermes-webui-hux/artifacts/endpoints.ts";
import {
ARTIFACTS_FLAG,
FOUNDATION_FLAG,
PROJECTS_FLAG,
artifactsEnabled,
normalizeArtifact,
normalizeArtifactWorkspace,
normalizePreview,
preservesImmutableHistory,
} from "../../dockerfiles/hermes-webui-hux/artifacts/model.ts";
import {
ArtifactContractError,
isMime,
isOpaqueId,
isSha256,
isUtc,
normalizeIdentity,
normalizeScope,
safeBlobUrl,
safePreviewText,
safeText,
sameIdentity,
sameScope,
scopedPath,
} from "../../dockerfiles/hermes-webui-hux/artifacts/security.ts";
const IDENTITY = {
tenantRef: "tnt_0123456789abcdef",
userRef: "usr_0123456789abcdef",
surface: "chat",
};
const RAW_IDENTITY = {
tenant_ref: IDENTITY.tenantRef,
user_ref: IDENTITY.userRef,
surface: IDENTITY.surface,
};
const SCOPE = {projectId: "prj_test1234", conversationId: "conv_test1234"};
const RAW_SCOPE = {project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId};
const FLAGS = [FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG];
const HASH_A = `sha256:${"a".repeat(64)}`;
const HASH_B = `sha256:${"b".repeat(64)}`;
const CONTRACT_TYPES = JSON.parse(
readFileSync("services/hermes/contracts/hux/artifact.schema.json"),
).properties.type.enum;
function version(number = 1, extra = {}) {
return {
version: number,
created_at: `2026-08-24T10:00:0${number}Z`,
created_by: {type: "assistant", id: "hermes", display: "Hermes"},
content_ref: {hash: number === 1 ? HASH_A : HASH_B, bytes: 120 * number, mime: "text/markdown"},
...(number > 1 ? {diff_from: number - 1} : {}),
note: `Draft ${number}`,
...extra,
};
}
function artifact(extra = {}) {
return {
schema: "hux.artifact.v1",
id: "art_test1234",
owner: IDENTITY.userRef,
conversation_id: SCOPE.conversationId,
project_id: SCOPE.projectId,
type: "markdown",
language: "report",
title: "Release report",
current_version: 2,
versions: [version(1), version(2)],
sensitivity: "personal",
created_at: "2026-08-24T10:00:01Z",
updated_at: "2026-08-24T10:00:02Z",
...extra,
};
}
function page(artifacts = [artifact()], extra = {}) {
return {
schema: "hux.artifact_workspace.v1",
api_version: "hux.v1",
identity: RAW_IDENTITY,
binding: RAW_SCOPE,
artifacts,
attachments: [{artifact_id: "art_test1234", source_ids: ["src_test1234"],
citation_ids: ["cit_test1234"]}],
authorizations: [{artifact_id: "art_test1234", download: "authorized",
share: "requires_approval"}],
...extra,
};
}
test("artifacts require the foundation and projects", () => {
assert.equal(artifactsEnabled(), false);
assert.equal(artifactsEnabled([]), false);
assert.equal(artifactsEnabled([ARTIFACTS_FLAG]), false);
assert.equal(artifactsEnabled([FOUNDATION_FLAG, ARTIFACTS_FLAG]), false);
assert.equal(artifactsEnabled([PROJECTS_FLAG, ARTIFACTS_FLAG]), false);
assert.equal(artifactsEnabled(FLAGS), true);
});
test("security primitives reject malformed scope and unsafe display data", () => {
assert.equal(isOpaqueId("art_test1234", "art"), true);
assert.equal(isOpaqueId("prj_test1234", "art"), false);
assert.equal(isOpaqueId("bad"), false);
assert.equal(isOpaqueId(4), false);
assert.equal(isUtc("2026-08-24T10:00:00.123Z"), true);
assert.equal(isUtc("2026-99-24T10:00:00Z"), false);
assert.equal(isUtc(4), false);
assert.equal(isSha256(HASH_A), true);
assert.equal(isSha256(`sha256:${"z".repeat(64)}`), false);
assert.equal(isSha256(4), false);
assert.equal(isMime("text/plain"), true);
assert.equal(isMime("bad"), false);
assert.equal(isMime("a/" + "b".repeat(121)), false);
assert.equal(isMime(4), false);
assert.equal(safeText({}, "fallback", 20), "fallback");
assert.equal(safeText(" \n ", "fallback", 20), "fallback");
assert.equal(safeText("abc\u0000def", "fallback", 20), "abc def");
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
assert.equal(safeText("token=secret Bearer abc.DEF", "fallback", 200), "[redacted] [redacted]");
assert.match(safeText("-----BEGIN PRIVATE KEY----- secret -----END PRIVATE KEY-----", "x", 200), /redacted/);
assert.equal(safePreviewText("line one\nline two"), "line one\nline two");
assert.equal(safePreviewText("bad\u0000content"), null);
assert.equal(safePreviewText("abc", 2), null);
assert.equal(safePreviewText({}), null);
assert.equal(safeBlobUrl("blob:https://chat.example/abc"), "blob:https://chat.example/abc");
for (const unsafe of ["https://example.test/image.png", "data:image/png;base64,x", "blob:has space", 4]) {
assert.equal(safeBlobUrl(unsafe), null);
}
});
test("identity and binding normalization fail closed", () => {
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
assert.deepEqual(normalizeScope(RAW_SCOPE), SCOPE);
assert.equal(sameIdentity(IDENTITY, {...IDENTITY}), true);
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
assert.equal(sameScope(SCOPE, {...SCOPE}), true);
assert.equal(sameScope(SCOPE, {...SCOPE, projectId: "prj_other1234"}), false);
for (const bad of [null, [], {}, {...RAW_IDENTITY, tenant_ref: "tenant"},
{...RAW_IDENTITY, user_ref: "user"}, {...RAW_IDENTITY, surface: "browser"}]) {
assert.throws(() => normalizeIdentity(bad), ArtifactContractError);
}
for (const bad of [null, [], {}, {...RAW_SCOPE, project_id: "bad"},
{...RAW_SCOPE, conversation_id: "bad"}]) {
assert.throws(() => normalizeScope(bad), ArtifactContractError);
}
});
test("schema types select safe renderers without live HTML or SVG", () => {
const cases = [
["markdown", "text/markdown", "", "document"],
["markdown", "text/plain", "research", "report"],
["document", "application/pdf", "analysis", "report"],
["document", "text/plain", "", "document"],
["code", "text/x-python", "python", "code"],
["html", "text/html", "html", "code"],
["svg", "image/svg+xml", "svg", "code"],
["json", "application/json", "json", "data"],
["json", "text/plain", "json", "data"],
["csv", "text/csv", "csv", "data"],
["csv", "text/plain", "csv", "data"],
["image", "image/png", "", "image"],
["image", "image/jpeg", "", "image"],
["image", "image/webp", "", "image"],
["image", "image/gif", "", "image"],
["audio", "audio/wav", "", "audio"],
["audio", "audio/mpeg", "", "audio"],
["audio", "audio/ogg", "", "audio"],
["audio", "audio/webm", "", "audio"],
];
assert.deepEqual(new Set(cases.map(([type]) => type)), new Set(CONTRACT_TYPES));
for (const [type, mime, language, renderer] of cases) {
const raw = artifact({type, language, current_version: 1,
versions: [version(1, {content_ref: {hash: HASH_A, bytes: 1, mime}})]});
assert.equal(normalizeArtifact(raw, IDENTITY.userRef, SCOPE)?.renderer, renderer);
}
});
test("valid artifact preserves immutable content metadata and promotion", () => {
const raw = artifact({
promotion: {project_id: SCOPE.projectId, version: 1, at: "2026-08-24T10:00:03Z"},
versions: [version(1), version(2, {
lineage: {artifact_id: "art_source1234", version: 3},
created_by: {type: "user", id: "private-user"},
})],
});
const item = normalizeArtifact(raw, IDENTITY.userRef, SCOPE);
assert.equal(item.promotedVersion, 1);
assert.equal(item.renderer, "report");
assert.equal(item.versions[1].createdBy, "user");
assert.deepEqual(item.versions[1].lineage, {artifactId: "art_source1234", version: 3});
assert.doesNotMatch(JSON.stringify(item), /private-user|Hermes/);
});
test("artifact records reject malformed, rewritten, and cross-scope history", () => {
const bad = [
artifact({schema: "hux.artifact.v0"}), artifact({id: "bad"}),
artifact({owner: "usr_ffffffffffffffff"}), artifact({project_id: "prj_other1234"}),
artifact({conversation_id: "conv_other1234"}), artifact({type: "binary"}),
artifact({current_version: 0}), artifact({current_version: 3}), artifact({versions: []}),
artifact({versions: "no"}), artifact({versions: Array.from({length: 201}, () => version(1))}),
artifact({sensitivity: "secret"}), artifact({created_at: "today"}),
artifact({updated_at: "today"}), artifact({updated_at: "2026-08-24T09:00:00Z"}),
artifact({title: ""}), artifact({title: {private: true}}),
artifact({versions: [version(1), version(1)]}),
artifact({versions: [version(1), version(2, {created_at: "2026-08-24T09:00:00Z"})]}),
artifact({versions: [version(1), version(2, {diff_from: 2})]}),
artifact({versions: [version(1), version(2, {lineage: {artifact_id: "art_test1234", version: 2}})]}),
artifact({promotion: null}),
artifact({promotion: {project_id: "prj_other1234", version: 1, at: "2026-08-24T10:00:03Z"}}),
artifact({promotion: {project_id: SCOPE.projectId, version: 3, at: "2026-08-24T10:00:03Z"}}),
artifact({promotion: {project_id: SCOPE.projectId, version: 1, at: "today"}}),
artifact({type: "image", current_version: 1, versions: [version(1)]}),
];
for (const raw of bad) assert.equal(normalizeArtifact(raw, IDENTITY.userRef, SCOPE), null);
const badVersions = [
version(1, {version: 0}), version(1, {created_at: "today"}), version(1, {created_by: null}),
version(1, {created_by: {type: "intruder", id: "x"}}),
version(1, {created_by: {type: "user", id: ""}}),
version(1, {content_ref: null}), version(1, {content_ref: {hash: "bad", bytes: 1, mime: "text/markdown"}}),
version(1, {content_ref: {hash: HASH_A, bytes: -1, mime: "text/markdown"}}),
version(1, {content_ref: {hash: HASH_A, bytes: 1, mime: "bad"}}),
version(1, {diff_from: 0}), version(1, {lineage: null}),
version(1, {lineage: {artifact_id: "bad", version: 1}}),
version(1, {lineage: {artifact_id: "art_source1234", version: 0}}),
];
for (const invalid of badVersions) {
assert.equal(normalizeArtifact(artifact({current_version: 1, versions: [invalid]}),
IDENTITY.userRef, SCOPE), null);
}
});
test("workspace envelope enforces tenant, user, project, and conversation binding", () => {
const result = normalizeArtifactWorkspace(page(), IDENTITY, SCOPE);
assert.equal(result.rejected, 0);
assert.equal(result.artifacts.length, 1);
assert.deepEqual(result.attachments, [{artifactId: "art_test1234", sourceCount: 1, citationCount: 1}]);
assert.deepEqual(result.authorizations, [{artifactId: "art_test1234", download: "authorized",
share: "requires_approval"}]);
for (const bad of [null, {schema: "old"}, page([], {api_version: "hux.v0"}),
page([], {identity: {...RAW_IDENTITY, tenant_ref: "tnt_ffffffffffffffff"}}),
page([], {identity: {...RAW_IDENTITY, user_ref: "usr_ffffffffffffffff"}}),
page([], {identity: {...RAW_IDENTITY, surface: "worker"}}),
page([], {binding: {...RAW_SCOPE, project_id: "prj_other1234"}}),
page([], {binding: {...RAW_SCOPE, conversation_id: "conv_other1234"}})]) {
assert.throws(() => normalizeArtifactWorkspace(bad, IDENTITY, SCOPE), /scope boundary|identity/);
}
});
test("workspace rejects bad entries and ignores unsafe attachment metadata", () => {
const duplicate = artifact();
const result = normalizeArtifactWorkspace(page([artifact(), duplicate, artifact({id: "bad"})], {
attachments: [
{artifact_id: "art_test1234", source_ids: ["src_test1234"], citation_ids: []},
{artifact_id: "art_test1234", source_ids: [], citation_ids: []},
{artifact_id: "art_unknown1", source_ids: [], citation_ids: []},
{artifact_id: "art_other1234", source_ids: ["bad"], citation_ids: []},
null,
],
authorizations: [
{artifact_id: "art_test1234", download: "authorized", share: "unavailable"},
{artifact_id: "art_test1234", download: "authorized", share: "authorized"},
{artifact_id: "art_unknown1", download: "authorized", share: "authorized"},
{artifact_id: "art_other1234", download: "yes", share: "authorized"},
null,
],
}), IDENTITY, SCOPE);
assert.equal(result.artifacts.length, 1);
assert.equal(result.rejected, 2);
assert.equal(result.attachments.length, 1);
assert.equal(result.authorizations.length, 1);
assert.deepEqual(normalizeArtifactWorkspace(page([], {artifacts: null, attachments: {},
authorizations: {}}), IDENTITY, SCOPE).artifacts, []);
});
test("previews require matching identity, binding, hash, MIME, and local object URLs", () => {
const item = normalizeArtifact(artifact(), IDENTITY.userRef, SCOPE);
const base = {schema: "hux.artifact_preview.v1", identity: RAW_IDENTITY, binding: RAW_SCOPE,
artifact_id: item.id, version: 2, hash: HASH_B, mime: "text/markdown", text: "# Safe report"};
assert.equal(normalizePreview(base, item, IDENTITY, SCOPE)?.text, "# Safe report");
const invalid = [null, {...base, schema: "old"}, {...base, identity: {...RAW_IDENTITY, surface: "worker"}},
{...base, binding: {...RAW_SCOPE, project_id: "prj_other1234"}}, {...base, artifact_id: "art_other1234"},
{...base, version: 3}, {...base, hash: HASH_A}, {...base, mime: "text/plain"},
{...base, text: "bad\u0000text"}, {...base, blob_url: "blob:local"}];
for (const raw of invalid) assert.equal(normalizePreview(raw, item, IDENTITY, SCOPE), null);
assert.equal(normalizePreview(base, item, {...IDENTITY, userRef: "bad"}, SCOPE), null);
const image = normalizeArtifact(artifact({type: "image", language: "", current_version: 1,
versions: [version(1, {content_ref: {hash: HASH_A, bytes: 5, mime: "image/png"}})]}),
IDENTITY.userRef, SCOPE);
const imagePreview = {...base, artifact_id: image.id, version: 1, hash: HASH_A, mime: "image/png",
text: undefined, blob_url: "blob:https://chat.example/image"};
assert.equal(normalizePreview(imagePreview, image, IDENTITY, SCOPE)?.blobUrl,
"blob:https://chat.example/image");
assert.equal(normalizePreview({...imagePreview, blob_url: "https://remote/image"}, image,
IDENTITY, SCOPE), null);
assert.equal(normalizePreview({...imagePreview, text: "inline"}, image, IDENTITY, SCOPE), null);
});
test("immutable history permits append-only versions but not rewrites or orphaning", () => {
const before = normalizeArtifact(artifact(), IDENTITY.userRef, SCOPE);
const appendedRaw = artifact({current_version: 3, updated_at: "2026-08-24T10:00:03Z",
versions: [...artifact().versions, version(3, {content_ref: {hash: HASH_A, bytes: 360,
mime: "text/markdown"}})]});
const appended = normalizeArtifact(appendedRaw, IDENTITY.userRef, SCOPE);
assert.equal(preservesImmutableHistory(before, before), true);
assert.equal(preservesImmutableHistory(before, appended), true);
const changes = [
{...before, id: "art_other1234"}, {...before, owner: "usr_ffffffffffffffff"},
{...before, projectId: "prj_other1234"}, {...before, conversationId: "conv_other1234"},
{...before, type: "document"}, {...before, versions: before.versions.slice(1), currentVersion: 1},
{...before, versions: [{...before.versions[0], note: "rewritten"}, before.versions[1]]},
{...before, currentVersion: 1},
];
for (const changed of changes) assert.equal(preservesImmutableHistory(before, changed), false);
});
test("endpoint contract is default-off, scoped, immutable, and optimistic", () => {
const client = {apiVersion: "hux.v1", identity: IDENTITY,
endpoint(path) { assert.match(path, /^\/projects\/prj_[^/]+\/conversations\/conv_[^/]+\/artifacts/);
return `/hux/v1${path}`; }};
assert.equal(createArtifactEndpointContract(client, SCOPE), null);
assert.equal(createArtifactEndpointContract(client, SCOPE, [FOUNDATION_FLAG, ARTIFACTS_FLAG]), null);
const contract = createArtifactEndpointContract(client, SCOPE, FLAGS);
assert.equal(contract.list.path, "/hux/v1/projects/prj_test1234/conversations/conv_test1234/artifacts");
assert.equal(contract.item("art_test1234").method, "GET");
assert.match(contract.preview("art_test1234", 2).path, /versions\/2\/preview$/);
assert.deepEqual(contract.diff("art_test1234", 1, 2).requiredBody,
["from_version", "to_version", "expected_current_version"]);
assert.match(contract.appendVersion("art_test1234").path, /versions$/);
assert.match(contract.continueEdit("art_test1234").path, /continue$/);
assert.match(contract.authorize("art_test1234", "download").path, /authorizations\/download$/);
assert.match(contract.download("art_test1234", 1).path, /versions\/1\/download$/);
assert.match(contract.share("art_test1234", 1).path, /versions\/1\/shares$/);
assert.match(contract.promote("art_test1234").path, /promotions$/);
assert.match(contract.sources("art_test1234").path, /sources$/);
for (const call of [() => contract.item("bad"), () => contract.preview("art_test1234", 0),
() => contract.diff("art_test1234", 2, 1), () => contract.authorize("art_test1234", "execute")]) {
assert.throws(call, TypeError);
}
assert.throws(() => createArtifactEndpointContract({...client, apiVersion: "hux.v2"}, SCOPE, FLAGS),
/HUX v1/);
assert.throws(() => createArtifactEndpointContract({...client,
identity: {...IDENTITY, tenantRef: "bad"}}, SCOPE, FLAGS), /identity/);
});
test("scoped path rejects traversal and malformed identifiers", () => {
assert.equal(scopedPath("/projects", SCOPE, "art_test1234"),
"/projects/prj_test1234/conversations/conv_test1234/artifacts/art_test1234");
for (const call of [() => scopedPath("https://remote", SCOPE), () => scopedPath("//remote", SCOPE),
() => scopedPath("/../projects", SCOPE), () => scopedPath("/projects?x", SCOPE),
() => scopedPath("/projects", {...SCOPE, projectId: "bad"}),
() => scopedPath("/projects", {...SCOPE, conversationId: "bad"}),
() => scopedPath("/projects", SCOPE, "bad")]) assert.throws(call, TypeError);
});

View File

@ -0,0 +1,76 @@
"""Executable and static quality gates for isolated HUX-04 artifacts."""
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ARTIFACTS = ROOT / "dockerfiles" / "hermes-webui-hux" / "artifacts"
def test_hux_artifact_model_node_suite_and_coverage():
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-branches=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/artifacts/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/artifacts/security.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/artifacts/endpoints.ts",
"testing/tests/test_hermes_hux_ui_artifacts.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_artifact_component_is_flagged_accessible_and_inert():
component = (ARTIFACTS / "ArtifactWorkspace.tsx").read_text(encoding="utf-8")
model = (ARTIFACTS / "model.ts").read_text(encoding="utf-8")
endpoints = (ARTIFACTS / "endpoints.ts").read_text(encoding="utf-8")
styles = (ARTIFACTS / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled || !page) return null" in component
assert "artifactsEnabled(props.flags)" in component
assert all(flag in model for flag in ("FOUNDATION_FLAG", "PROJECTS_FLAG", "ARTIFACTS_FLAG"))
assert 'aria-label="Artifact workspace"' in component
assert 'aria-live="polite"' in component
assert "Immutable version history" in component
assert "Continue editing" in component
assert "Promote version to project" in component
assert "Sources attached" in component and "Citations attached" in component
assert "dangerouslySetInnerHTML" not in component
assert "<iframe" not in component and "<object" not in component
assert "fetch(" not in endpoints and "axios" not in endpoints
assert "expected_current_version" in endpoints
assert '"PATCH"' not in endpoints and '"DELETE"' not in endpoints
assert ".hux-artifacts" in styles
assert "body" not in styles and ":root" not in styles
assert "prefers-reduced-motion" in styles
def test_hux_artifact_sources_respect_size_and_isolated_scope():
sources = sorted(ARTIFACTS.glob("*"))
assert {path.name for path in sources} == {
"ArtifactWorkspace.tsx",
"endpoints.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
assert "dockerfiles/hermes-webui-hux/artifacts" not in (
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
).read_text(encoding="utf-8")

View File

@ -0,0 +1,313 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createAutonomyEndpointContract } from "../../dockerfiles/hermes-webui-hux/autonomy/endpoints.ts";
import {
ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, BUDGET_MAX, CAPABILITIES,
FOUNDATION_FLAG, actionAuthorization, autonomyControlsEnabled,
buildApprovalDecision, buildPolicyUpdate, buildStopIntent,
normalizeAutonomyPage, normalizeStopReceipt,
} from "../../dockerfiles/hermes-webui-hux/autonomy/model.ts";
import {
boundedInteger, exactKeys, identityKey, isOpaqueId, isUtc, normalizeIdentity,
safeText, sameIdentity,
} from "../../dockerfiles/hermes-webui-hux/autonomy/security.ts";
const IDENTITY = {
tenantRef: "tnt_0123456789abcdef",
userRef: "usr_0123456789abcdef",
surface: "chat",
};
const CONVERSATION = "conv_test1234";
const NOW = Date.parse("2026-08-24T10:05:00Z");
const grants = (decision = "ask") => CAPABILITIES.map((capability) => ({capability, decision}));
function policy(extra = {}) {
return {
schema: "hux.policy.v1",
id: "pol_test1234",
owner: IDENTITY.userRef,
scope: {level: "conversation", scope_id: CONVERSATION},
autonomy: "safe",
grants: grants(),
budgets: {
tokens_per_run: 200000,
tool_calls_per_run: 40,
wall_clock_seconds: 900,
delegations_per_run: 4,
},
provenance: {surface: "chat"},
updated_at: "2026-08-24T10:00:00Z",
...extra,
};
}
function approval(id = "apr_test1234", extra = {}) {
return {
schema: "hux.approval.v1",
id,
run_id: "run_test1234",
conversation_id: CONVERSATION,
capability: "send_message",
request: {summary: "Send the completed report", risk: "high"},
status: "pending",
requested_at: "2026-08-24T10:00:00Z",
expires_at: "2026-08-24T10:10:00Z",
...extra,
};
}
function envelope(extra = {}) {
return {
schema: "hux.autonomy_page.v1",
api_version: "hux.v1",
identity: {tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: "chat"},
conversation_id: CONVERSATION,
policy: policy(),
approvals: [approval()],
...extra,
};
}
test("HUX-05 is inert unless all three dependency flags are present", () => {
assert.equal(autonomyControlsEnabled(), false);
assert.equal(autonomyControlsEnabled([]), false);
assert.equal(autonomyControlsEnabled([AUTONOMY_FLAG]), false);
assert.equal(autonomyControlsEnabled([FOUNDATION_FLAG, AUTONOMY_FLAG]), false);
assert.equal(autonomyControlsEnabled([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG]), false);
assert.equal(autonomyControlsEnabled([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]), true);
});
test("security helpers reject unscoped identity and redact display text", () => {
assert.deepEqual(normalizeIdentity({tenant_ref: IDENTITY.tenantRef,
user_ref: IDENTITY.userRef, surface: "chat"}), IDENTITY);
assert.deepEqual(normalizeIdentity(IDENTITY), IDENTITY);
assert.equal(normalizeIdentity(null), null);
assert.equal(normalizeIdentity([]), null);
assert.equal(normalizeIdentity({...IDENTITY, tenantRef: "tenant"}), null);
assert.equal(normalizeIdentity({...IDENTITY, userRef: "brad@example.test"}), null);
assert.equal(normalizeIdentity({...IDENTITY, surface: "browser"}), null);
assert.equal(sameIdentity(IDENTITY, {...IDENTITY}), true);
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
assert.match(identityKey(IDENTITY), /^tnt_.*:usr_.*:chat$/);
assert.equal(isOpaqueId("apr_test1234", "apr"), true);
assert.equal(isOpaqueId("pol_test1234", "apr"), false);
assert.equal(isOpaqueId(4), false);
assert.equal(isUtc("2026-08-24T10:00:00.123Z"), true);
assert.equal(isUtc("2026-99-24T10:00:00Z"), false);
assert.equal(isUtc(4), false);
assert.equal(boundedInteger(5, 5), 5);
assert.equal(boundedInteger(-1, 5), null);
assert.equal(boundedInteger(6, 5), null);
assert.equal(boundedInteger(1.5, 5), null);
assert.equal(exactKeys({b: 1, a: 2}, ["a", "b"]), true);
assert.equal(exactKeys({a: 1, extra: 2}, ["a"]), false);
assert.equal(safeText({}, "fallback"), "fallback");
assert.equal(safeText(" \n ", "fallback"), "fallback");
assert.equal(safeText("token=secret Bearer abc.def\u0000", "fallback"),
"token=[redacted] Bearer [redacted]");
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
});
test("policy and pending approvals normalize against exact identity and scope", () => {
const page = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW);
assert.ok(page);
assert.equal(page.policy.autonomy, "safe");
assert.deepEqual(page.policy.scope, {level: "conversation", scopeId: CONVERSATION});
assert.deepEqual(page.policy.budgets, {
tokensPerRun: 200000, toolCallsPerRun: 40, wallClockSeconds: 900,
delegationsPerRun: 4,
});
assert.equal(page.approvals.length, 1);
assert.equal(page.approvals[0].risk, "high");
assert.equal(page.rejectedApprovals, 0);
const partial = normalizeAutonomyPage(envelope({approvals: [
approval(),
approval("apr_expired1", {expires_at: "2026-08-24T10:04:59Z"}),
approval("apr_cross123", {conversation_id: "conv_other1234"}),
approval("apr_secret12", {request: {summary: "token=do-not-show", risk: "low"}}),
]}), IDENTITY, CONVERSATION, NOW);
assert.equal(partial.approvals.length, 2);
assert.equal(partial.rejectedApprovals, 2);
assert.doesNotMatch(partial.approvals[1].summary, /do-not-show/);
});
test("page normalization rejects malformed envelopes, policies, and budgets", () => {
const invalidEnvelopes = [
null,
{...envelope(), schema: "hux.autonomy_page.v0"},
{...envelope(), api_version: "hux.v0"},
{...envelope(), conversation_id: "bad"},
{...envelope(), identity: {...envelope().identity, user_ref: "usr_ffffffffffffffff"}},
{...envelope(), identity: null},
{...envelope(), approvals: {}},
{...envelope(), approvals: Array(101).fill(approval())},
{...envelope(), policy: {...policy(), surprise: true}},
{...envelope(), policy: {...policy(), schema: "old"}},
{...envelope(), policy: {...policy(), id: "bad"}},
{...envelope(), policy: {...policy(), owner: "usr_ffffffffffffffff"}},
{...envelope(), policy: {...policy(), autonomy: "reckless"}},
{...envelope(), policy: {...policy(), updated_at: "today"}},
{...envelope(), policy: {...policy(), scope: null}},
{...envelope(), policy: {...policy(), scope: {level: "global", scope_id: CONVERSATION}}},
{...envelope(), policy: {...policy(), scope: {level: "project", scope_id: "bad"}}},
{...envelope(), policy: {...policy(), scope: {level: "planet"}}},
{...envelope(), policy: {...policy(), grants: {}}},
{...envelope(), policy: {...policy(), grants: Array(65).fill({capability: "shell", decision: "ask"})}},
{...envelope(), policy: {...policy(), grants: [{capability: "unknown", decision: "ask"}]}},
{...envelope(), policy: {...policy(), grants: [{capability: "shell", decision: "maybe"}]}},
{...envelope(), policy: {...policy(), grants: [{capability: "shell", decision: "ask"}, {capability: "shell", decision: "deny"}]}},
{...envelope(), policy: {...policy(), budgets: null}},
{...envelope(), policy: {...policy(), budgets: {unknown: 1}}},
{...envelope(), policy: {...policy(), budgets: {tokens_per_run: BUDGET_MAX.tokensPerRun + 1}}},
];
for (const value of invalidEnvelopes) {
assert.equal(normalizeAutonomyPage(value, IDENTITY, CONVERSATION, NOW), null);
}
});
test("approval rows fail closed when malformed, resolved, stale, or reordered", () => {
const invalid = [
null,
{...approval(), extra: true},
{...approval(), schema: "old"},
{...approval(), id: "bad"},
{...approval(), run_id: ""},
{...approval(), run_id: "x".repeat(121)},
{...approval(), capability: "unknown"},
{...approval(), status: "approved"},
{...approval(), requested_at: "today"},
{...approval(), expires_at: "today"},
{...approval(), requested_at: "2026-08-24T10:11:00Z"},
{...approval(), request: null},
{...approval(), request: {summary: "x", risk: "extreme"}},
{...approval(), request: {summary: "x", risk: "low", injected: true}},
];
const page = normalizeAutonomyPage(envelope({approvals: invalid}), IDENTITY, CONVERSATION, NOW);
assert.deepEqual(page.approvals, []);
assert.equal(page.rejectedApprovals, invalid.length);
});
test("policy updates preserve canonical budgets and require bounded spend and scope", () => {
const page = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW);
const draft = {...page.policy.budgets, spendCentsPerRun: 2500, scopeLimit: "conversation"};
const intent = buildPolicyUpdate(page.policy, IDENTITY, "autonomous", page.policy.grants, draft);
assert.equal(intent.expectedPolicyId, page.policy.id);
assert.equal(intent.guardrails.spendCentsPerRun, 2500);
assert.equal("spendCentsPerRun" in intent.budgets, false);
assert.deepEqual(Object.keys(intent.grants), CAPABILITIES);
const invalidDrafts = [
{...draft, tokensPerRun: -1}, {...draft, toolCallsPerRun: 501},
{...draft, wallClockSeconds: 86401}, {...draft, delegationsPerRun: 33},
{...draft, spendCentsPerRun: 100001}, {...draft, scopeLimit: "internet"},
];
for (const bad of invalidDrafts) assert.throws(() =>
buildPolicyUpdate(page.policy, IDENTITY, "safe", page.policy.grants, bad), /bounds/);
assert.throws(() => buildPolicyUpdate(page.policy, {...IDENTITY, userRef: "usr_ffffffffffffffff"},
"safe", page.policy.grants, draft), /bounds/);
assert.throws(() => buildPolicyUpdate(page.policy, IDENTITY, "turbo", page.policy.grants, draft), /bounds/);
assert.throws(() => buildPolicyUpdate(page.policy, IDENTITY, "safe",
{...page.policy.grants, shell: "maybe"}, draft), /bounds/);
});
test("authorization requires exact live approval whenever policy says ask", () => {
const page = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW);
const pending = page.approvals[0];
assert.equal(actionAuthorization(null, "read_files", null, IDENTITY, NOW), "deny");
assert.equal(actionAuthorization({...page.policy, owner: "usr_ffffffffffffffff"},
"read_files", null, IDENTITY, NOW), "deny");
assert.equal(actionAuthorization(page.policy, "unknown", null, IDENTITY, NOW), "deny");
assert.equal(actionAuthorization(page.policy, "send_message", null, IDENTITY, NOW), "approval_required");
assert.equal(actionAuthorization(page.policy, "send_message", pending, IDENTITY, NOW), "approval_required");
assert.equal(actionAuthorization(page.policy, "shell", pending, IDENTITY, NOW), "approval_required");
assert.equal(actionAuthorization(page.policy, "send_message", {...pending, status: "denied"}, IDENTITY, NOW), "approval_required");
assert.equal(actionAuthorization(page.policy, "send_message", {...pending, expiresAt: "2026-08-24T10:04:00Z"}, IDENTITY, NOW), "approval_required");
assert.equal(actionAuthorization(page.policy, "send_message", {...pending, identityKey: "cross"}, IDENTITY, NOW), "approval_required");
const allow = {...page.policy, autonomy: "autonomous",
grants: {...page.policy.grants, read_files: "allow", send_message: "allow"}};
assert.equal(actionAuthorization(allow, "read_files", null, IDENTITY, NOW), "allow");
assert.equal(actionAuthorization(allow, "send_message", null, IDENTITY, NOW), "allow");
const safe = {...allow, autonomy: "safe"};
assert.equal(actionAuthorization(safe, "read_files", null, IDENTITY, NOW), "allow");
assert.equal(actionAuthorization(safe, "send_message", null, IDENTITY, NOW), "approval_required");
assert.equal(actionAuthorization({...allow, grants: {...allow.grants, deploy: "deny"}},
"deploy", null, IDENTITY, NOW), "deny");
});
test("approval decision proves identity and original timestamp", () => {
const item = normalizeAutonomyPage(envelope(), IDENTITY, CONVERSATION, NOW).approvals[0];
for (const choice of ["once", "session", "always", "deny"]) {
const intent = buildApprovalDecision(item, choice, IDENTITY, item.requestedAt, NOW);
assert.equal(intent.choice, choice);
assert.equal(intent.expectedRunId, item.runId);
assert.equal(intent.expectedConversationId, CONVERSATION);
}
assert.throws(() => buildApprovalDecision(item, "once", IDENTITY, "2026-08-24T10:00:01Z", NOW), /denied/);
assert.throws(() => buildApprovalDecision(item, "once", {...IDENTITY, surface: "worker"}, item.requestedAt, NOW), /denied/);
assert.throws(() => buildApprovalDecision({...item, status: "denied"}, "once", IDENTITY, item.requestedAt, NOW), /denied/);
assert.throws(() => buildApprovalDecision({...item, expiresAt: "2026-08-24T10:04:00Z"}, "once", IDENTITY, item.requestedAt, NOW), /denied/);
assert.throws(() => buildApprovalDecision(item, "later", IDENTITY, item.requestedAt, NOW), /denied/);
});
test("stop intent and receipt require exact ownership and remain bounded", () => {
const run = {runId: "run_test1234", conversationId: CONVERSATION,
tenantRef: IDENTITY.tenantRef, owner: IDENTITY.userRef};
assert.deepEqual(buildStopIntent(run, IDENTITY, run.runId), {
runId: run.runId, conversationId: CONVERSATION, identity: IDENTITY,
});
for (const bad of [
{...run, runId: ""}, {...run, runId: "x".repeat(121)},
{...run, conversationId: "bad"}, {...run, owner: "usr_ffffffffffffffff"},
{...run, tenantRef: "tnt_ffffffffffffffff"},
]) assert.throws(() => buildStopIntent(bad, IDENTITY, run.runId), /ownership/);
assert.throws(() => buildStopIntent(run, IDENTITY, "run_stale1234"), /ownership/);
const raw = {
schema: "hux.cancel_receipt.v1", id: "rcpt_test1234", run_id: run.runId,
requested_by: {type: "user", id: IDENTITY.userRef},
requested_at: "2026-08-24T10:05:00Z", outcome: "cancelled",
side_effects: Array.from({length: 7}, (_, index) => ({
description: index ? `Effect ${index}` : "token=do-not-show", reverted: index % 2 === 0,
})),
};
const receipt = normalizeStopReceipt(raw, run, IDENTITY);
assert.equal(receipt.effects.length, 4);
assert.equal(receipt.omittedEffects, 3);
assert.equal(receipt.stopped, 2);
assert.equal(receipt.remaining, 2);
assert.doesNotMatch(JSON.stringify(receipt), /do-not-show/);
assert.equal(normalizeStopReceipt({...raw, run_id: "run_other1234"}, run, IDENTITY), null);
assert.equal(normalizeStopReceipt({...raw, requested_by: null}, run, IDENTITY), null);
assert.equal(normalizeStopReceipt({...raw, requested_by: {type: "system", id: IDENTITY.userRef}}, run, IDENTITY), null);
assert.equal(normalizeStopReceipt({...raw, requested_by: {type: "user", id: "usr_ffffffffffffffff"}}, run, IDENTITY), null);
assert.equal(normalizeStopReceipt({...raw, schema: "old"}, run, IDENTITY), null);
assert.equal(normalizeStopReceipt(raw, {...run, owner: "usr_ffffffffffffffff"}, IDENTITY), null);
assert.equal(normalizeStopReceipt(raw, {...run, tenantRef: "tnt_ffffffffffffffff"}, IDENTITY), null);
});
test("endpoint adapter is inert, same-origin, versioned, and release-aware", () => {
const enabled = new Set([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]);
const client = {
apiVersion: "hux.v1", identity: IDENTITY,
enabled: (flag) => enabled.has(flag), endpoint: (path) => `/hux/v1${path}`,
};
const contract = createAutonomyEndpointContract(client);
assert.equal(contract.policy.path, "/hux/v1/autonomy/policy");
assert.equal(contract.updatePolicy.responseSchema, "hux.policy.v1");
assert.equal(contract.approvals.path, "/hux/v1/approvals?status=pending&limit=100");
assert.match(contract.decideApproval("apr_test1234").path, /\/approvals\/apr_test1234\/decision$/);
assert.match(contract.stop("run_test1234").path, /\/runs\/run_test1234\/stop$/);
assert.deepEqual(contract.release, {schema: "hux.release.v1", requiredState: "live_verified",
featureFlags: [FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG]});
assert.throws(() => contract.decideApproval("bad"), /Opaque/);
assert.throws(() => contract.stop("bad"), /Exact/);
for (const flag of enabled) {
assert.equal(createAutonomyEndpointContract({...client, enabled: (candidate) =>
candidate !== flag && enabled.has(candidate)}), null);
}
assert.equal(createAutonomyEndpointContract({...client, apiVersion: "hux.v0"}), null);
assert.throws(() => createAutonomyEndpointContract({...client,
identity: {...IDENTITY, userRef: "raw-email"}}), /identity/);
});

View File

@ -0,0 +1,95 @@
"""Executable and static quality gates for isolated HUX-05 frontend."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
AUTONOMY = ROOT / "dockerfiles" / "hermes-webui-hux" / "autonomy"
def test_hux_autonomy_model_node_suite_and_coverage():
"""Fail-closed policy decisions retain at least 95% executable coverage."""
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/autonomy/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/autonomy/security.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/autonomy/endpoints.ts",
"testing/tests/test_hermes_hux_ui_autonomy.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_autonomy_component_is_accessible_flagged_and_inert():
"""Static checks preserve accessible controls and callback-only effects."""
component = (AUTONOMY / "AutonomyControls.tsx").read_text(encoding="utf-8")
model = (AUTONOMY / "model.ts").read_text(encoding="utf-8")
endpoints = (AUTONOMY / "endpoints.ts").read_text(encoding="utf-8")
styles = (AUTONOMY / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled) return null" in component
assert "autonomyControlsEnabled(props.flags)" in component
for flag in ("hux.foundation", "hux.activity_timeline", "hux.autonomy"):
assert flag in model
assert "<fieldset" in component and "<legend" in component
assert 'type="radio"' in component and '<table>' in component
assert "Stop current run" in component and "Review external side effects" in component
assert 'role="status"' in component and 'aria-live="polite"' in component
assert "dangerouslySetInnerHTML" not in component
assert "fetch(" not in component and "fetch(" not in endpoints
assert "hux.v1" in endpoints and "hux.release.v1" in endpoints
assert "live_verified" in endpoints and "hux.cancel_receipt.v1" in endpoints
assert ".hux-autonomy" in styles
assert "body" not in styles and ":root" not in styles
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
def test_hux_autonomy_matches_permission_contract_and_stays_bounded():
"""Canonical values match the source schema and stay out of live images."""
schema = json.loads(
(ROOT / "services/hermes/contracts/hux/permission.schema.json").read_text(
encoding="utf-8"
)
)
model = (AUTONOMY / "model.ts").read_text(encoding="utf-8")
types = (AUTONOMY / "types.ts").read_text(encoding="utf-8")
for value in schema["$defs"]["capability"]["enum"]:
assert f'"{value}"' in model
for value in schema["$defs"]["policy"]["properties"]["autonomy"]["enum"]:
assert f'"{value}"' in model
for value in schema["$defs"]["decision"]["enum"]:
assert f'"{value}"' in model
for value in schema["$defs"]["approval"]["properties"]["decision"][
"properties"
]["choice"]["enum"]:
assert f'"{value}"' in types
assert {path.name for path in AUTONOMY.iterdir()} == {
"AutonomyControls.tsx",
"endpoints.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in AUTONOMY.iterdir():
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
dockerfiles = list((ROOT / "dockerfiles").glob("Dockerfile.hermes*"))
assert all("hermes-webui-hux/autonomy" not in path.read_text(encoding="utf-8") for path in dockerfiles)

View File

@ -0,0 +1,68 @@
"""Execute the dependency-free HUX browser foundation contract tests."""
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
NODE_TEST = ROOT / "testing/tests/test_hermes_hux_ui_foundation_node.js"
HUX_UI = ROOT / "dockerfiles/hermes-webui-hux"
def test_hux_ui_foundation_node_contracts():
"""The browser modules pass their functional and isolation checks."""
result = subprocess.run(
["node", "--test", str(NODE_TEST)],
cwd=ROOT,
capture_output=True,
check=False,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "fail 0" in result.stdout
def test_hux_ui_foundation_is_inert_and_bounded():
"""The foundation stays separate from the active image patch surface."""
production = sorted(path for path in HUX_UI.iterdir() if path.suffix in {".js", ".css"})
assert {path.name for path in production} == {
"bootstrap.css",
"bootstrap.js",
"foundation.css",
"foundation.js",
"shell.js",
}
for path in production:
assert len(path.read_text(encoding="utf-8").splitlines()) < 500
dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text(encoding="utf-8")
# The WebUI image ships the HUX browser surface, but only from sources that
# exist in this tree, and it never bakes activated HUX flags into the image:
# the single HUX_FLAGS occurrence is the empty-flag build-time router check.
copies = [
line.split()[1]
for line in dockerfile.splitlines()
if line.startswith("COPY dockerfiles/hermes-webui-hux")
]
assert copies
for source in copies:
assert (ROOT / source).is_file(), source
assert dockerfile.count("HUX_FLAGS") == 1
assert '"HUX_FLAGS": ""' in dockerfile
def test_hux_ui_foundation_never_persists_or_renders_raw_payloads():
"""Static guardrails forbid browser storage and raw-record display paths."""
source = (HUX_UI / "foundation.js").read_text(encoding="utf-8")
assert "localStorage" not in source
assert "sessionStorage" not in source
assert "innerHTML" not in source
assert "record.detail" not in source
assert "item.uri" not in source
assert "item.hash" not in source
assert "credentials: 'same-origin'" in source

View File

@ -0,0 +1,276 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const test = require('node:test');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.resolve(__dirname, '..', '..');
const foundation = require(path.join(ROOT, 'dockerfiles/hermes-webui-hux/foundation.js'));
const shellApi = require(path.join(ROOT, 'dockerfiles/hermes-webui-hux/shell.js'));
const IDENTITY = Object.freeze({
tenant_ref: 'tnt_0123456789abcdef',
user_ref: 'usr_0123456789abcdef',
surface: 'chat',
});
function capability(flags, identity = IDENTITY) {
return {schema: 'hux.capabilities.v1', api_version: 'hux.v1', identity, flags};
}
function response(status, body) {
return {status, ok: status >= 200 && status < 300, json: async () => body};
}
test('capability negotiation is versioned, dependency-aware, and immutable', () => {
const result = foundation.normalizeCapabilities(capability([
'hux.foundation', 'hux.activity_timeline', 'hux.autonomy', 'hux.multimodal', 'hux.bogus',
]), foundation.normalizeIdentity(IDENTITY));
assert.deepEqual(result.flags, ['hux.activity_timeline', 'hux.autonomy', 'hux.foundation']);
assert.equal(result.apiVersion, 'hux.v1');
assert(Object.isFrozen(result));
assert(Object.isFrozen(result.identity));
assert.throws(() => foundation.normalizeCapabilities({}, result.identity), /Unsupported/);
assert.throws(() => foundation.normalizeCapabilities([], result.identity), /not an object/);
assert.throws(() => foundation.normalizeCapabilities(capability('bad'), result.identity), /flags/);
assert.throws(() => foundation.normalizeCapabilities(capability([], {...IDENTITY, surface: 'api'}), result.identity), /does not match/);
});
test('browser script path exports only the versioned namespace', () => {
const context = {globalThis: {}};
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'dockerfiles/hermes-webui-hux/foundation.js'), 'utf8'), context);
assert.equal(context.globalThis.HermesHuxFoundation.API_VERSION, 'hux.v1');
context.globalThis.HermesHuxFoundation = context.globalThis.HermesHuxFoundation;
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'dockerfiles/hermes-webui-hux/shell.js'), 'utf8'), context);
assert.equal(typeof context.globalThis.HermesHuxShell.createShell, 'function');
});
test('identity accepts only opaque tenant and user references', () => {
assert.deepEqual(foundation.normalizeIdentity(IDENTITY), {
tenantRef: IDENTITY.tenant_ref, userRef: IDENTITY.user_ref, surface: 'chat',
});
assert.throws(() => foundation.normalizeIdentity(null), /Missing/);
assert.throws(() => foundation.normalizeIdentity({...IDENTITY, tenant_ref: 'tenant-0'}), /tenant/);
assert.throws(() => foundation.normalizeIdentity({...IDENTITY, user_ref: 'brad@example.com'}), /user/);
assert.throws(() => foundation.normalizeIdentity({...IDENTITY, surface: 'browser'}), /surface/);
});
test('client stays off for 404 and reports safe states without response bodies', async () => {
const calls = [];
let result = response(404, {});
const client = foundation.createClient({
expectedIdentity: IDENTITY,
fetcher: async (...args) => { calls.push(args); return result; },
});
const states = [];
const unsubscribe = client.subscribe((state) => states.push(state.phase));
assert.equal(client.getState().phase, 'disabled');
await client.negotiate();
assert.deepEqual(states, ['disabled', 'loading', 'disabled']);
assert.equal(calls[0][0], '/hux/v1/capabilities');
assert.equal(calls[0][1].credentials, 'same-origin');
assert.equal(client.enabled('hux.foundation'), false);
result = response(200, capability([]));
await client.negotiate();
assert.equal(client.getState().phase, 'disabled');
result = response(500, {secret: 'must not escape'});
await client.negotiate();
assert.equal(client.getState().phase, 'error');
assert.equal(client.getState().error.includes('secret'), false);
unsubscribe();
});
test('client enables verified flags and rejects unsafe endpoint construction', async () => {
const client = foundation.createClient({
expectedIdentity: IDENTITY,
baseUrl: '/hux/v1/',
fetcher: async () => response(200, capability(['hux.foundation', 'hux.projects'])),
});
await client.negotiate();
assert.equal(client.getState().phase, 'ready');
assert.equal(client.enabled('hux.projects'), true);
assert.equal(client.enabled('hux.bogus'), false);
assert.equal(client.endpoint('/projects'), '/hux/v1/projects');
assert.throws(() => client.endpoint('https://evil.example/x'), /scoped/);
assert.throws(() => client.endpoint('//evil.example/x'), /scoped/);
assert.throws(() => client.endpoint('/../other-tenant'), /scoped/);
assert.throws(() => foundation.createClient({expectedIdentity: IDENTITY, baseUrl: 'https://evil.example'}), /same-origin/);
assert.throws(() => foundation.createClient({expectedIdentity: IDENTITY, fetcher: null}), /fetch/);
assert.throws(() => client.subscribe('nope'), /Listener/);
});
test('client discards late negotiation results', async () => {
const resolvers = [];
const client = foundation.createClient({
expectedIdentity: IDENTITY,
fetcher: () => new Promise((resolve) => resolvers.push(resolve)),
});
const first = client.negotiate();
const second = client.negotiate();
resolvers[1](response(200, capability(['hux.foundation'])));
await second;
resolvers[0](response(404, {}));
await first;
assert.equal(client.getState().phase, 'ready');
});
test('event adapter exposes summaries and references, never details or URIs', () => {
const adapters = foundation.createAdapters({identity: IDENTITY, conversationId: 'conv_0001abcd'});
const rawEvent = {
schema: 'hux.event.v1', id: 'evt_0001aaaa', seq: 2,
ts: '2026-08-24T00:00:00Z', conversation_id: 'conv_0001abcd',
kind: 'tool.result', summary: 'Checked service health', detail: {raw: 'private output'},
evidence: [{kind: 'tool_result', id: 'call-1', uri: 'https://secret.example', hash: 'sha256:no'}],
sensitivity: 'personal', redaction: {level: 'none'},
};
const event = adapters.adaptEvent(rawEvent);
assert.equal(event.summary, 'Checked service health');
assert.deepEqual(event.evidence, [{kind: 'tool_result', id: 'call-1'}]);
assert.equal('detail' in event, false);
assert(Object.isFrozen(event.evidence[0]));
const hidden = adapters.adaptEvent({...rawEvent, redaction: {level: 'full'}});
assert.equal(hidden.summary, 'Details hidden by privacy controls.');
assert.equal(hidden.redacted, true);
assert.throws(() => adapters.adaptEvent({...rawEvent, conversation_id: 'conv_other'}), /conversation/);
assert.throws(() => adapters.adaptEvent({...rawEvent, seq: -1}), /activity/);
assert.throws(() => adapters.adaptEvent({...rawEvent, summary: ''}), /summary/);
assert.throws(() => adapters.adaptEvent({...rawEvent, ts: 'today'}), /timestamp/);
assert.throws(() => adapters.adaptEvent({...rawEvent, kind: 'tool.raw'}), /classification/);
assert.throws(() => adapters.adaptEvent({...rawEvent, redaction: {level: 'maybe'}}), /redaction/);
const withoutConversation = {...rawEvent};
delete withoutConversation.conversation_id;
assert.throws(() => adapters.adaptEvent(withoutConversation), /matching conversation/);
assert.throws(() => adapters.adaptEvent(null), /not an object/);
});
test('object adapter is display-only and enforces owner and conversation', () => {
const adapters = foundation.createAdapters({identity: IDENTITY, conversationId: 'conv_0001abcd'});
const view = adapters.adaptObject({
schema: 'hux.artifact.v1', id: 'art_0001aaaa', owner: IDENTITY.user_ref,
conversation_id: 'conv_0001abcd', project_id: 'prj_0001aaaa', title: 'Plan',
type: 'markdown', status: 'ready', sensitivity: 'personal', content: 'never expose me',
updated_at: '2026-08-24T00:00:00Z',
});
assert.deepEqual(view, {
schema: 'hux.artifact.v1', id: 'art_0001aaaa', title: 'Plan', kind: 'markdown',
status: 'ready', sensitivity: 'personal', conversationId: 'conv_0001abcd',
projectId: 'prj_0001aaaa', updatedAt: '2026-08-24T00:00:00Z',
});
assert.equal('content' in view, false);
assert.throws(() => adapters.adaptObject({...view, owner: 'usr_ffffffffffffffff'}), /user boundary/);
assert.throws(() => adapters.adaptObject({...view, schema: 'hux.artifact.v2'}), /version/);
assert.throws(() => foundation.createAdapters({identity: IDENTITY, conversationId: 'bad'}), /conversation id/);
});
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.attributes = {};
this.children = [];
this.dataset = {};
this.hidden = false;
this.parentNode = null;
this.listeners = {};
this.textContent = '';
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name.startsWith('data-')) {
const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
this.dataset[key] = String(value);
}
}
getAttribute(name) { return this.attributes[name]; }
appendChild(node) { node.parentNode = this; this.children.push(node); return node; }
removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null; }
replaceChildren(...nodes) { this.children.forEach((node) => { node.parentNode = null; }); this.children = []; nodes.forEach((node) => this.appendChild(node)); }
addEventListener(name, listener) { this.listeners[name] = listener; }
click() { if (this.listeners.click) this.listeners.click({target: this}); }
focus() { this.focused = true; }
keydown(key) { if (this.listeners.keydown) this.listeners.keydown({key, preventDefault() {}}); }
}
function fakeClient() {
let state = Object.freeze({phase: 'disabled', capabilities: null, error: null});
let listener = null;
let retries = 0;
return {
enabled: (flag) => state.phase === 'ready' && state.capabilities.flags.includes(flag),
getState: () => state,
negotiate: () => { retries += 1; },
retries: () => retries,
subscribe: (next) => { listener = next; next(state); return () => { listener = null; }; },
setState: (next) => { state = Object.freeze(next); if (listener) listener(state); },
};
}
function descendants(node) {
return node.children.flatMap((child) => [child, ...descendants(child)]);
}
test('accessible shell stays hidden until an extension capability is verified', () => {
const client = fakeClient();
const host = new FakeNode('main');
const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}});
shell.register({id: 'projects', flag: 'hux.projects', label: 'Projects', order: 20, render: ({container}) => { container.textContent = 'Projects view'; }});
shell.register({id: 'activity', flag: 'hux.activity_timeline', label: 'Activity', order: 10, render: ({container}) => { container.textContent = 'Activity view'; }});
const root = shell.mount(host);
assert.equal(root.hidden, true);
assert.equal(root.getAttribute('data-hux-version'), 'hux.v1');
client.setState({phase: 'loading', capabilities: null, error: null});
assert.equal(root.hidden, false);
assert.equal(descendants(root)[0].getAttribute('role'), 'status');
client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation', 'hux.activity_timeline', 'hux.projects']}, error: null});
const tabs = descendants(root).filter((node) => node.getAttribute('role') === 'tab');
const panels = descendants(root).filter((node) => node.getAttribute('role') === 'tabpanel');
assert.deepEqual(tabs.map((tab) => tab.textContent), ['Activity', 'Projects']);
assert.equal(tabs[0].getAttribute('aria-selected'), 'true');
assert.equal(panels[0].hidden, false);
tabs[1].click();
assert.equal(tabs[1].getAttribute('aria-selected'), 'true');
assert.equal(panels[0].hidden, true);
assert.equal(panels[1].hidden, false);
tabs[1].keydown('ArrowLeft');
assert.equal(tabs[0].focused, true);
assert.equal(tabs[0].getAttribute('aria-selected'), 'true');
client.setState({phase: 'error', capabilities: null, error: 'Safe error'});
const retry = descendants(root).find((node) => node.textContent === 'Try again');
retry.click();
assert.equal(client.retries(), 1);
shell.destroy();
assert.equal(host.children.length, 0);
});
test('shell rejects malformed and duplicate extensions', () => {
const client = fakeClient();
const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}});
assert.throws(() => shell.register({id: 'X', flag: 'hux.projects', label: 'x', render() {}}), /id/);
assert.throws(() => shell.register({id: 'base', flag: 'hux.foundation', label: 'x', render() {}}), /feature flag/);
assert.throws(() => shell.register({id: 'unknown', flag: 'hux.nope', label: 'x', render() {}}), /feature flag/);
assert.throws(() => shell.register({id: 'projects', flag: 'hux.projects', label: '', render() {}}), /label/);
const remove = shell.register({id: 'projects', flag: 'hux.projects', label: 'Projects', render() {}});
assert.throws(() => shell.register({id: 'projects', flag: 'hux.projects', label: 'Again', render() {}}), /already/);
remove();
assert.throws(() => shellApi.createShell({}), /client and document/);
});
test('shell hides empty ready state and contains extension renderer failure', () => {
const client = fakeClient();
const host = new FakeNode('main');
const shell = shellApi.createShell({client, document: {createElement: (tag) => new FakeNode(tag)}});
const root = shell.mount(host);
client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation']}, error: null});
assert.equal(root.hidden, true);
shell.register({id: 'broken-panel', flag: 'hux.projects', label: 'Broken', render() { throw new Error('raw private error'); }});
client.setState({phase: 'ready', capabilities: {flags: ['hux.foundation', 'hux.projects']}, error: null});
const safe = descendants(root).find((node) => node.textContent.includes('temporarily unavailable'));
assert(safe);
assert.equal(safe.textContent.includes('private error'), false);
});

View File

@ -0,0 +1,334 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createMemoryEndpointContract } from "../../dockerfiles/hermes-webui-hux/memory/endpoints.ts";
import {
FOUNDATION_FLAG,
MEMORY_CONTROL_FLAG,
MEMORY_TOPICS,
PRIVACY_FLAG,
memoryControlEnabled,
normalizeMemoryPage,
} from "../../dockerfiles/hermes-webui-hux/memory/model.ts";
import {
MemoryContractError,
isControlReference,
isOpaqueMemoryId,
isUtcTimestamp,
normalizeIdentity,
safeMemoryText,
sameIdentity,
} from "../../dockerfiles/hermes-webui-hux/memory/security.ts";
const IDENTITY = {
tenantRef: "tnt_0123456789abcdef",
userRef: "usr_0123456789abcdef",
surface: "chat",
};
const FLAGS = [FOUNDATION_FLAG, PRIVACY_FLAG, MEMORY_CONTROL_FLAG];
function entry(index = 1, extra = {}) {
return {
schema: "hux.memory.v1",
id: `mem_test${String(index).padStart(4, "0")}`,
owner: IDENTITY.userRef,
scope: { level: "conversation", scope_id: "conv_test1234" },
kind: "preference",
content: `Concise answer preference ${index}`,
status: "active",
approval_mode: "ask",
sensitivity: "personal",
topic: "general",
ttl: { policy: "decay", decay_days: 180 },
source: { kind: "message", id: `msg-${index}`, uri: "https://secret" },
provenance: {
surface: "chat",
actor: { type: "assistant", id: "hermes", display: "do not copy" },
recorded_at: "2026-08-24T10:00:00Z",
route: { requested: "thoughtful", resolved_target: "secret-model" },
},
created_at: "2026-08-24T10:00:00Z",
updated_at: `2026-08-24T10:00:${String(index % 60).padStart(2, "0")}Z`,
audit: [
{
at: "2026-08-24T10:00:00Z",
action: "approved",
actor: { type: "user", id: "private-subject" },
note: "Useful response preference",
},
],
...extra,
};
}
function page(entries, extra = {}) {
return {
schema: "hux.memory_page.v1",
identity: {
tenant_ref: IDENTITY.tenantRef,
user_ref: IDENTITY.userRef,
surface: IDENTITY.surface,
},
entries,
...extra,
};
}
test("memory control requires foundation, privacy, and memory flags", () => {
assert.equal(memoryControlEnabled(), false);
assert.equal(memoryControlEnabled([]), false);
assert.equal(memoryControlEnabled([MEMORY_CONTROL_FLAG]), false);
assert.equal(memoryControlEnabled([FOUNDATION_FLAG, MEMORY_CONTROL_FLAG]), false);
assert.equal(memoryControlEnabled([FOUNDATION_FLAG, PRIVACY_FLAG]), false);
assert.equal(memoryControlEnabled(FLAGS), true);
});
test("memory topics stay aligned with the existing memory and privacy schemas", () => {
const memory = JSON.parse(
readFileSync("services/hermes/contracts/hux/memory.schema.json"),
);
const privacy = JSON.parse(
readFileSync("services/hermes/contracts/hux/privacy.schema.json"),
);
assert.deepEqual(MEMORY_TOPICS, memory.properties.topic.enum);
assert.deepEqual(
MEMORY_TOPICS.filter((topic) => topic !== "general"),
privacy.$defs.topic.enum,
);
});
test("display text scrubs credentials, controls, and object payloads", () => {
assert.equal(safeMemoryText({}, "fallback"), "fallback");
assert.equal(safeMemoryText(" \n ", "fallback"), "fallback");
assert.equal(safeMemoryText("abc\u0000def", "fallback"), "abc def");
assert.equal(safeMemoryText("abcdef", "fallback", 4), "abc…");
assert.equal(safeMemoryText("abcdef", "fallback", 0), "…");
const scrubbed = safeMemoryText(
[
"password=hunter2",
"Bearer abc.DEF_123",
"eyJabcdefgh.abcdefghijkl.abcdefghijkl",
"https://user:pass@example.test",
"Abcdefghijklmnopqrstuvwxyz1234567890ABCD",
"-----BEGIN PRIVATE KEY----- nope -----END PRIVATE KEY-----",
].join(" "),
"fallback",
1000,
);
assert.doesNotMatch(scrubbed, /hunter2|abc\.DEF|user:pass|1234567890ABCD|nope/);
assert.match(scrubbed, /redacted/);
});
test("scope primitives reject malformed identities, ids, and timestamps", () => {
assert.equal(isOpaqueMemoryId("mem_abcd"), true);
assert.equal(isOpaqueMemoryId("evt_abcd"), false);
assert.equal(isOpaqueMemoryId(4), false);
assert.equal(isControlReference("msg-42"), true);
assert.equal(isControlReference("conv_test1234", "conv"), true);
assert.equal(isControlReference("msg-42", "conv"), false);
assert.equal(isControlReference("../message"), false);
assert.equal(isControlReference(4), false);
assert.equal(isUtcTimestamp("2026-08-24T10:00:00.123Z"), true);
assert.equal(isUtcTimestamp("2026-90-24T10:00:00Z"), false);
assert.equal(isUtcTimestamp(4), false);
assert.deepEqual(normalizeIdentity(IDENTITY), IDENTITY);
assert.equal(sameIdentity(IDENTITY, { ...IDENTITY }), true);
assert.equal(sameIdentity(IDENTITY, { ...IDENTITY, surface: "worker" }), false);
for (const bad of [null, [], {}, { ...IDENTITY, tenantRef: "tenant" }, { ...IDENTITY, userRef: "user" }, { ...IDENTITY, surface: "web" }]) {
assert.throws(() => normalizeIdentity(bad), MemoryContractError);
}
});
test("a valid ledger exposes useful labels but no raw provenance or source ids", () => {
const result = normalizeMemoryPage(
page([
entry(1),
entry(2, {
scope: { level: "global" },
ttl: { policy: "never" },
kind: "fact",
topic: "finance",
source: { kind: "file", id: "/private/path" },
provenance: {
surface: "chat",
actor: { type: "user", id: "private-user" },
recorded_at: "2026-08-24T10:00:00Z",
},
audit: [{ action: "approved", actor: { type: "user", id: "user" } }],
}),
entry(3, {
scope: { level: "project", scope_id: "prj_test1234" },
ttl: { policy: "expires_at", expires_at: "2026-09-24T10:00:00Z" },
kind: "instruction",
status: "proposed",
source: { kind: "approval", id: "apr-secret" },
audit: [{ note: "token=do-not-display" }],
}),
], { next_cursor: "cursor.2" }),
IDENTITY,
);
assert.equal(result.invalid, 0);
assert.equal(result.nextCursor, "cursor.2");
assert.deepEqual(result.entries.map((item) => item.id), ["mem_test0003", "mem_test0002", "mem_test0001"]);
assert.equal(result.entries[0].scopeLabel, "This project");
assert.equal(result.entries[0].ttlLabel, "Expires 2026-09-24T10:00:00Z");
assert.equal(result.entries[0].reason, "token=[redacted]");
assert.equal(result.entries[1].scopeLabel, "All conversations");
assert.equal(result.entries[1].ttlLabel, "Kept until you forget it");
assert.equal(result.entries[1].reason, "Saved for future help");
assert.equal(result.entries[1].sourceLabel, "Workspace file");
assert.equal(result.entries[1].sourceIsMessage, false);
assert.equal(result.entries[2].sourceIsMessage, true);
assert.equal(result.entries[1].provenanceLabel, "user via chat");
const serialized = JSON.stringify(result.entries);
assert.doesNotMatch(serialized, /private\/path|private-user|private-subject|secret-model|apr-secret|do-not-display/);
});
test("forgotten and sensitive values fail closed without leaking reasons", () => {
const forbidden = "kidney stone and account secret";
const result = normalizeMemoryPage(
page([
entry(1, { status: "forgotten", content: forbidden, audit: [{ note: forbidden }] }),
entry(2, { sensitivity: "sensitive", topic: "health", content: forbidden, audit: [{ note: forbidden }] }),
entry(3, { sensitivity: "restricted", topic: "credentials", content: forbidden }),
entry(4, { sensitivity: "public", content: "visible" }),
]),
IDENTITY,
);
const [publicItem, restricted, sensitive, forgotten] = result.entries;
assert.equal(publicItem.content, "visible");
assert.equal(restricted.content, null);
assert.equal(sensitive.content, null);
assert.equal(forgotten.content, null);
assert.equal(forgotten.contentNotice, "Content permanently removed");
assert.match(sensitive.contentNotice, /privacy controls/);
assert.doesNotMatch(JSON.stringify(result), new RegExp(forbidden));
assert.equal(sensitive.reason, "Reason hidden by privacy controls");
});
test("an omitted optional topic defaults to general without changing approval", () => {
const item = normalizeMemoryPage(
page([
entry(8, {
topic: undefined,
status: "proposed",
audit: [{ action: "proposed", actor: { type: "assistant", id: "hermes" } }],
}),
]),
IDENTITY,
).entries[0];
assert.equal(item.topic, "general");
assert.equal(item.approvalMode, "ask");
assert.equal(item.reason, "Hermes suggested this for your approval");
});
test("malformed or cross-owner entries are withheld independently", () => {
const invalid = [
entry(1, { schema: "hux.memory.v0" }),
entry(2, { id: "evt_bad" }),
entry(3, { owner: "usr_ffffffffffffffff" }),
entry(4, { kind: "password" }),
entry(5, { status: "deleted" }),
entry(6, { sensitivity: "secret" }),
entry(7, { topic: "politics" }),
entry(8, { approval_mode: "yes" }),
entry(9, { created_at: "today" }),
entry(10, { updated_at: "today" }),
entry(11, { provenance: null }),
entry(12, { provenance: { surface: "worker", actor: { type: "assistant" }, recorded_at: "2026-08-24T10:00:00Z" } }),
entry(13, { provenance: { surface: "chat", actor: { type: "assistant" }, recorded_at: "today" } }),
entry(14, { provenance: { surface: "chat", actor: null, recorded_at: "2026-08-24T10:00:00Z" } }),
entry(15, { provenance: { surface: "chat", actor: { type: "intruder" }, recorded_at: "2026-08-24T10:00:00Z" } }),
entry(16, { source: null }),
entry(17, { source: { kind: "raw_tool_log", id: "secret" } }),
entry(18, { source: { kind: "message", id: "" } }),
entry(19, { source: { kind: "message", id: "x".repeat(201) } }),
entry(20, { ttl: { policy: "expires_at", expires_at: "today" } }),
entry(21, { ttl: { policy: "decay", decay_days: 0 } }),
entry(22, { ttl: { policy: "decay", decay_days: 3651 } }),
entry(23, { ttl: {} }),
entry(24, { scope: null }),
entry(25, { scope: { level: "global", scope_id: "should-not-exist" } }),
entry(26, { scope: { level: "project" } }),
entry(27, { scope: { level: "conversation" } }),
entry(28, { content: {} }),
entry(29, { content: "x".repeat(2001) }),
entry(30, { audit: [] }),
entry(31, { audit: null }),
entry(32, { provenance: { surface: "chat", actor: { type: "assistant", id: "" }, recorded_at: "2026-08-24T10:00:00Z" } }),
entry(33, { provenance: { surface: "chat", actor: { type: "assistant", id: "x".repeat(121) }, recorded_at: "2026-08-24T10:00:00Z" } }),
];
const result = normalizeMemoryPage(page([entry(99), ...invalid]), IDENTITY);
assert.equal(result.entries.length, 1);
assert.equal(result.invalid, invalid.length);
assert.equal(result.nextCursor, null);
});
test("response identity mismatches fail the whole page closed", () => {
assert.throws(() => normalizeMemoryPage(null, IDENTITY), MemoryContractError);
assert.throws(() => normalizeMemoryPage({ schema: "old" }, IDENTITY), MemoryContractError);
assert.throws(
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, tenant_ref: "tnt_ffffffffffffffff" } }), IDENTITY),
/scope boundary/,
);
assert.throws(
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, user_ref: "usr_ffffffffffffffff" } }), IDENTITY),
/scope boundary/,
);
assert.throws(
() => normalizeMemoryPage(page([], { identity: { ...page([]).identity, surface: "worker" } }), IDENTITY),
/scope boundary/,
);
assert.throws(
() => normalizeMemoryPage(page(null), IDENTITY),
/entries are missing/,
);
});
test("page bounds retain newest valid ledger entries", () => {
const many = Array.from({ length: 205 }, (_, index) => entry(index + 1));
const result = normalizeMemoryPage(page(many, { next_cursor: 7 }), IDENTITY);
assert.equal(result.entries.length, 200);
assert.equal(result.truncated, 5);
assert.equal(result.invalid, 0);
assert.equal(result.nextCursor, null);
assert.ok(result.entries[0].updatedAt >= result.entries.at(-1).updatedAt);
});
test("endpoint contract is inert by default and describes same-origin calls only", () => {
const client = {
apiVersion: "hux.v1",
identity: IDENTITY,
endpoint(path) {
assert.match(path, /^\/[a-z0-9/_-]+$/);
return `/hux/v1${path}`;
},
};
assert.equal(createMemoryEndpointContract(client), null);
assert.equal(createMemoryEndpointContract(client, [MEMORY_CONTROL_FLAG]), null);
const contract = createMemoryEndpointContract(client, FLAGS);
assert.deepEqual(contract.list, {
method: "GET",
path: "/hux/v1/memories",
responseSchema: "hux.memory_page.v1",
});
assert.deepEqual(contract.identity, IDENTITY);
assert.throws(() => { contract.identity.surface = "worker"; }, TypeError);
assert.equal(contract.suggestOnly.method, "PUT");
assert.equal(contract.doNotRemember.path, "/hux/v1/memory-blocks");
assert.equal(contract.action("mem_abcd", "approve").path, "/hux/v1/memories/mem_abcd/approve");
assert.equal(contract.action("mem_abcd", "reject").method, "POST");
assert.equal(contract.action("mem_abcd", "forget").responseSchema, "hux.memory.v1");
assert.equal(contract.revise("mem_abcd").path, "/hux/v1/memories/mem_abcd/revisions");
assert.throws(() => contract.action("bad", "approve"), /Opaque/);
assert.throws(() => contract.action("mem_abcd", "delete"), /Unsupported/);
assert.throws(
() => createMemoryEndpointContract({ ...client, apiVersion: "hux.v2" }, FLAGS),
/HUX v1/,
);
assert.throws(
() => createMemoryEndpointContract({ ...client, identity: { ...IDENTITY, userRef: "bad" } }, FLAGS),
MemoryContractError,
);
});

View File

@ -0,0 +1,76 @@
"""Executable and static quality gates for isolated HUX-02 memory controls."""
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MEMORY = ROOT / "dockerfiles" / "hermes-webui-hux" / "memory"
def test_hux_memory_model_node_suite_and_coverage():
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-branches=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/memory/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/memory/security.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/memory/endpoints.ts",
"testing/tests/test_hermes_hux_ui_memory.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_memory_component_is_flagged_accessible_and_privacy_safe():
component = (MEMORY / "MemoryControlCenter.tsx").read_text(encoding="utf-8")
model = (MEMORY / "model.ts").read_text(encoding="utf-8")
endpoints = (MEMORY / "endpoints.ts").read_text(encoding="utf-8")
styles = (MEMORY / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled) return null" in component
assert "workspace scope could not be verified" in component
assert "memoryControlEnabled(props.flags)" in component
assert all(name in model for name in ("FOUNDATION_FLAG", "PRIVACY_FLAG", "MEMORY_CONTROL_FLAG"))
assert 'aria-label="Memory ledger"' in component
assert 'role="status"' in component
assert "Suggest only" in component
assert "Edit as new entry" in component and "Delete &amp; forget" in component
assert "View source message" in component
assert "This message" in component and "Block topic" in component
assert "dangerouslySetInnerHTML" not in component
assert "JSON.stringify" not in component
assert "source.id" not in component and "actor.id" not in component
assert "fetch(" not in endpoints and "axios" not in endpoints
assert ".hux-memory" in styles
assert "body" not in styles and ":root" not in styles
assert "prefers-reduced-motion" in styles
def test_hux_memory_sources_respect_size_and_isolated_scope():
sources = sorted(MEMORY.glob("*"))
assert {path.name for path in sources} == {
"MemoryControlCenter.tsx",
"endpoints.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
assert "dockerfiles/hermes-webui-hux/memory" not in (
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
).read_text(encoding="utf-8")

View File

@ -0,0 +1,305 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
MODE_ORDER,
buildModeRequest,
createFriendlyModesAdapter,
friendlyModesEnabled,
normalizeAdvancedRoutes,
normalizeMode,
normalizeModeCatalog,
normalizeResolvedRoute,
} from "../../dockerfiles/hermes-webui-hux/modes/model.ts";
const constraints = (providers, localOnly = false) => ({
providers,
local_only: localOnly,
effort: { min: "low", max: "high" },
tools: {
web: "allowed",
shell: "allowed",
artifacts: "allowed",
delegate: "allowed",
},
memory: { read: true, write: true },
citations_required: false,
retention: "default",
});
const labels = {
fast: "Fast",
thoughtful: "Thoughtful",
research: "Research",
create: "Create",
private: "Private",
};
function mode(name, extra = {}) {
const isPrivate = name === "private";
return {
schema: "hux.mode.v1",
mode: name,
label: labels[name],
intent: `${labels[name]} response intent`,
constraints: constraints(
isPrivate ? ["local"] : ["claude", "codex"],
isPrivate,
),
switchyard: { route_id: `atlas/auto/${name}` },
...extra,
};
}
const catalog = () => MODE_ORDER.map((name) => mode(name));
test("friendly modes remain default-off and depend on HUX foundation", () => {
assert.equal(friendlyModesEnabled(), false);
assert.equal(friendlyModesEnabled([]), false);
assert.equal(friendlyModesEnabled([FOUNDATION_FLAG]), false);
assert.equal(friendlyModesEnabled([FRIENDLY_MODES_FLAG]), false);
assert.equal(
friendlyModesEnabled([FOUNDATION_FLAG, FRIENDLY_MODES_FLAG]),
true,
);
});
test("valid catalog is canonical and automatic modes preserve both pools", () => {
const normalized = normalizeModeCatalog(catalog());
assert.ok(normalized);
assert.deepEqual(normalized.map((item) => item.mode), MODE_ORDER);
for (const intent of normalized.slice(0, -1)) {
assert.deepEqual(intent.constraints.providers, ["codex", "claude"]);
assert.equal(intent.constraints.local_only, false);
assert.match(intent.switchyard.route_id, /^atlas\/auto\//);
assert.equal("override_route_id" in intent.switchyard, false);
}
assert.deepEqual(normalized.at(-1).constraints.providers, ["local"]);
assert.equal(normalized.at(-1).constraints.local_only, true);
});
test("catalog rejects missing, duplicate, and malformed entries", () => {
assert.equal(normalizeModeCatalog({}), null);
assert.equal(normalizeModeCatalog(catalog().slice(1)), null);
assert.equal(normalizeModeCatalog([...catalog().slice(0, 4), mode("fast")]), null);
assert.equal(normalizeModeCatalog([...catalog().slice(0, 4), null]), null);
});
test("mode validation is exact and fail-closed", () => {
assert.equal(normalizeMode(null), null);
assert.equal(normalizeMode({ ...mode("fast"), surprise: true }), null);
assert.equal(normalizeMode({ ...mode("fast"), schema: "hux.mode.v0" }), null);
assert.equal(normalizeMode({ ...mode("fast"), mode: "turbo" }), null);
assert.equal(normalizeMode({ ...mode("fast"), label: "Claude Fast" }), null);
assert.equal(normalizeMode({ ...mode("fast"), intent: "" }), null);
assert.equal(normalizeMode({ ...mode("fast"), intent: "x".repeat(401) }), null);
assert.equal(
normalizeMode({ ...mode("fast"), switchyard: { route_id: "vendor/default" } }),
null,
);
assert.equal(
normalizeMode({
...mode("fast"),
switchyard: { route_id: "atlas/auto/claude/fast" },
}),
null,
);
assert.equal(
normalizeMode({ ...mode("fast"), intent: "Prefer OpenAI for this mode" }),
null,
);
assert.equal(
normalizeMode({
...mode("fast"),
switchyard: {
route_id: "atlas/auto/fast",
override_route_id: "atlas/manual/codex/high",
},
}),
null,
);
});
test("constraint validation rejects provider bias and malformed nested policy", () => {
const invalid = [
constraints([], false),
constraints(["codex"], false),
constraints(["claude"], false),
constraints(["codex", "codex"], false),
constraints(["codex", "unknown"], false),
{ ...constraints(["codex", "claude"]), local_only: true },
{ ...constraints(["codex", "claude"]), effort: { min: "high", max: "low" } },
{ ...constraints(["codex", "claude"]), tools: null },
{
...constraints(["codex", "claude"]),
tools: { ...constraints([]).tools, web: "sometimes" },
},
{ ...constraints(["codex", "claude"]), memory: { read: true } },
{ ...constraints(["codex", "claude"]), citations_required: "no" },
{ ...constraints(["codex", "claude"]), retention: "forever" },
{ ...constraints(["codex", "claude"]), secret: "value" },
];
for (const value of invalid) {
assert.equal(normalizeMode({ ...mode("fast"), constraints: value }), null);
}
});
test("Private is strictly local-only while non-private can include local", () => {
assert.equal(
normalizeMode({ ...mode("private"), constraints: constraints(["codex"], true) }),
null,
);
assert.equal(
normalizeMode({ ...mode("private"), constraints: constraints(["local"], false) }),
null,
);
assert.ok(
normalizeMode({
...mode("research"),
constraints: constraints(["local", "claude", "codex"]),
}),
);
});
test("advanced catalog keeps only bounded exact manual route choices", () => {
const raw = [
{
route_id: "atlas/manual/codex/high",
label: "Deep route",
provider: "codex",
effort: "high",
local_only: false,
},
{
route_id: "atlas/manual/local/private",
label: "Local route",
provider: "local",
effort: "medium",
local_only: true,
},
{
route_id: "atlas/auto/claude/high",
label: "Not a pin",
provider: "claude",
effort: "high",
local_only: false,
},
{
route_id: "atlas/manual/codex/high",
label: "Duplicate",
provider: "codex",
effort: "high",
local_only: false,
},
null,
];
assert.deepEqual(normalizeAdvancedRoutes({}), []);
assert.deepEqual(
normalizeAdvancedRoutes(raw).map((choice) => choice.routeId),
["atlas/manual/codex/high", "atlas/manual/local/private"],
);
});
test("exact pin is created only from an explicit compatible advanced choice", () => {
const automatic = normalizeMode(mode("fast"));
const privateMode = normalizeMode(mode("private"));
const choices = normalizeAdvancedRoutes([
{
route_id: "atlas/manual/codex/high",
label: "OpenAI high",
provider: "codex",
effort: "high",
local_only: false,
},
{
route_id: "atlas/manual/local/private",
label: "Local",
provider: "local",
effort: "medium",
local_only: true,
},
]);
assert.deepEqual(buildModeRequest(automatic), automatic);
assert.equal(buildModeRequest(null), null);
assert.equal(buildModeRequest(automatic, "atlas/manual/unknown", choices), null);
assert.equal(
buildModeRequest(automatic, "atlas/manual/local/private", choices),
null,
);
assert.equal(
buildModeRequest(privateMode, "atlas/manual/codex/high", choices),
null,
);
assert.equal(
buildModeRequest(privateMode, "atlas/manual/local/private", choices)
.switchyard.override_route_id,
"atlas/manual/local/private",
);
assert.equal(
buildModeRequest(automatic, "atlas/manual/codex/high", choices).switchyard
.override_route_id,
"atlas/manual/codex/high",
);
});
test("resolved route disclosure validates the shared hux.v1 route shape", () => {
assert.deepEqual(
normalizeResolvedRoute({
requested: "fast",
resolved_target: "route/codex/luna/low",
provider: "codex",
effort: "low",
}),
{
requested: "fast",
target: "route/codex/luna/low",
provider: "codex",
effort: "low",
},
);
assert.deepEqual(normalizeResolvedRoute({ requested: "private" }), {
requested: "private",
target: undefined,
provider: undefined,
effort: undefined,
});
for (const invalid of [
null,
{},
{ requested: "" },
{ requested: "fast", provider: "gemini" },
{ requested: "fast", effort: "ultra" },
{ requested: "fast", raw: "secret" },
{ requested: "fast", resolved_target: 7 },
]) {
assert.equal(normalizeResolvedRoute(invalid), null);
}
});
test("foundation adapter preserves version, flags, endpoint, and validators", () => {
const calls = [];
const foundation = {
apiVersion: "hux.v1",
enabled: (flag) => flag === FOUNDATION_FLAG || flag === FRIENDLY_MODES_FLAG,
endpoint: (path) => (calls.push(path), `/hux/v1${path}`),
};
const adapter = createFriendlyModesAdapter(foundation);
assert.equal(adapter.apiVersion, "hux.v1");
assert.equal(adapter.enabled(), true);
assert.equal(adapter.endpoint(), "/hux/v1/modes");
assert.deepEqual(calls, ["/modes"]);
assert.ok(adapter.adaptCatalog(catalog()));
assert.equal(adapter.adaptResolvedRoute({}), null);
assert.ok(adapter.buildRequest(normalizeMode(mode("fast"))));
assert.throws(() => createFriendlyModesAdapter(null), TypeError);
assert.throws(
() => createFriendlyModesAdapter({ ...foundation, apiVersion: "hux.v0" }),
TypeError,
);
assert.equal(
createFriendlyModesAdapter({ ...foundation, enabled: () => false }).enabled(),
false,
);
});

View File

@ -0,0 +1,83 @@
"""Executable and static quality gates for the isolated HUX-06 frontend."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MODES = ROOT / "dockerfiles" / "hermes-webui-hux" / "modes"
def test_hux_modes_model_node_suite_and_coverage():
"""Mode intent validation is covered above the repository threshold."""
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/modes/model.ts",
"testing/tests/test_hermes_hux_ui_modes.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_modes_component_is_flagged_accessible_and_provider_neutral():
"""Static UI guardrails preserve keyboard access and neutral routing."""
component = (MODES / "FriendlyModeSelector.tsx").read_text(encoding="utf-8")
model = (MODES / "model.ts").read_text(encoding="utf-8")
styles = (MODES / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled) return null" in component
assert "friendlyModesEnabled(flags)" in component
assert "<fieldset" in component and "<legend" in component
assert 'type="radio"' in component and "aria-describedby" in component
assert "<details" in component and "<summary" in component
assert "<select" in component and 'aria-live="polite"' in component
assert "Automatic — use all allowed pools" in component
assert "codex" in model and "claude" in model and "local" in model
assert "preferred_provider" not in model
assert "default_provider" not in model
assert "dangerouslySetInnerHTML" not in component
assert ".hux-modes" in styles
assert "body" not in styles and ":root" not in styles
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
def test_hux_modes_matches_contract_and_stays_inert_and_bounded():
"""HUX-06 uses the declared schema without entering the live build."""
schema = json.loads(
(ROOT / "services/hermes/contracts/hux/mode.schema.json").read_text(
encoding="utf-8"
)
)
model = (MODES / "model.ts").read_text(encoding="utf-8")
assert schema["properties"]["schema"]["const"] == "hux.mode.v1"
for mode in schema["properties"]["mode"]["enum"]:
assert f'"{mode}"' in model
assert "hux.mode.v1" in model and "hux.v1" in model
assert {path.name for path in MODES.iterdir()} == {
"FriendlyModeSelector.tsx",
"index.ts",
"model.ts",
"styles.css",
"types.ts",
}
for path in MODES.iterdir():
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
assert "hermes-webui-hux/modes" not in (
ROOT / "dockerfiles/Dockerfile.hermes-webui"
).read_text(encoding="utf-8")

View File

@ -0,0 +1,269 @@
import assert from "node:assert/strict";
import test from "node:test";
import {createMultimodalEndpointContract} from "../../dockerfiles/hermes-webui-hux/multimodal/endpoints.ts";
import {
ARTIFACTS_FLAG, AUTONOMY_FLAG, FOUNDATION_FLAG, MULTIMODAL_FLAG, PROJECTS_FLAG,
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
} from "../../dockerfiles/hermes-webui-hux/multimodal/model.ts";
import {
classifyMime, isOpaqueId, isUtc, normalizeIdentity, normalizeScope, safeBlobUrl,
safeEndpointPart, safePreviewText, safeText, sameIdentity, sameScope, validateUpload,
} from "../../dockerfiles/hermes-webui-hux/multimodal/security.ts";
const IDENTITY = {tenantRef: "tnt_0123456789abcdef", userRef: "usr_0123456789abcdef", surface: "chat"};
const RAW_IDENTITY = {tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: "chat"};
const SCOPE = {projectId: "prj_test1234", conversationId: "conv_test1234"};
const RAW_SCOPE = {project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId};
const FLAGS = [FOUNDATION_FLAG, PROJECTS_FLAG, ARTIFACTS_FLAG, AUTONOMY_FLAG, MULTIMODAL_FLAG];
function media(extra = {}) {
return {schema: "hux.media.v1", id: "med_image1234", owner: IDENTITY.userRef,
project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId,
artifact_id: "art_image1234", artifact_version: 1, kind: "image", file_name: "garden.png",
mime: "image/png", bytes: 1200, preview_url: "blob:https://chat.bstein.dev/123",
preview_text: null, preview_authorized: true, alt_text: "A garden", annotations: [], lineage: null, ...extra};
}
function document(extra = {}) {
return media({id: "med_document1", artifact_id: "art_document1", kind: "document",
file_name: "notes.pdf", mime: "application/pdf", preview_url: null,
preview_text: "Safe document\npreview", preview_authorized: true,
annotations: [{id: "ann_note1234", page: 2, note: "Check this paragraph"}], ...extra});
}
function variant(extra = {}) {
return {id: "var_first1234", media_id: "med_image1234", artifact_id: "art_image1234",
artifact_version: 1, parent_variant_id: null, created_at: "2026-08-24T10:00:00Z", ...extra};
}
function thread(extra = {}) {
return {id: "thr_garden123", owner: IDENTITY.userRef, project_id: SCOPE.projectId,
conversation_id: SCOPE.conversationId, prompt: "A calm garden", variants: [variant()], ...extra};
}
function transcript(extra = {}) {
return {schema: "hux.voice_transcript.v1", id: "trn_voice1234", turn_id: "turn_voice123",
owner: IDENTITY.userRef, project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId,
revision: 2, language: "en", text: "A clean transcript", finalized: true, ...extra};
}
function page(extra = {}) {
return {schema: "hux.multimodal_page.v1", api_version: "hux.v1", identity: RAW_IDENTITY,
binding: RAW_SCOPE, media: [media(), document()], threads: [thread()],
transcript: transcript(), upload_authorization: "authorized", ...extra};
}
function policy(decision = "allow", autonomy = "autonomous") {
return {id: "pol_policy123", owner: IDENTITY.userRef, scope: {level: "conversation",
scopeId: SCOPE.conversationId}, autonomy, grants: {network: decision}, budgets: {
tokensPerRun: 1, toolCallsPerRun: 1, wallClockSeconds: 1, delegationsPerRun: 0},
updatedAt: "2026-08-24T10:00:00Z"};
}
test("HUX-07 is inert unless every dependency is active", () => {
assert.equal(multimodalEnabled(), false);
assert.equal(multimodalEnabled([]), false);
for (const flag of FLAGS) assert.equal(multimodalEnabled(FLAGS.filter((item) => item !== flag)), false);
assert.equal(multimodalEnabled(FLAGS), true);
});
test("security primitives validate identity, binding, ids, time, URLs, and text", () => {
assert.equal(isOpaqueId("med_image1234", "med"), true);
assert.equal(isOpaqueId("art_image1234", "med"), false);
assert.equal(isOpaqueId(4), false);
assert.equal(isUtc("2026-08-24T10:00:00.12Z"), true);
assert.equal(isUtc("today"), false);
assert.equal(isUtc(4), false);
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
assert.deepEqual(normalizeIdentity(IDENTITY), IDENTITY);
assert.equal(normalizeIdentity(null), null);
assert.equal(normalizeIdentity([]), null);
for (const bad of [{...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"},
{...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(bad), null);
assert.deepEqual(normalizeScope(RAW_SCOPE), SCOPE);
assert.deepEqual(normalizeScope(SCOPE), SCOPE);
assert.equal(normalizeScope(null), null);
assert.equal(normalizeScope([]), null);
assert.equal(normalizeScope({...RAW_SCOPE, project_id: "bad"}), null);
assert.equal(normalizeScope({...RAW_SCOPE, conversation_id: "bad"}), null);
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
assert.equal(sameIdentity(null, IDENTITY), false);
assert.equal(sameIdentity({...IDENTITY, surface: "voice"}, IDENTITY), false);
assert.equal(sameScope(SCOPE, SCOPE), true);
assert.equal(sameScope(null, SCOPE), false);
assert.equal(sameScope({...SCOPE, projectId: "prj_other1234"}, SCOPE), false);
assert.equal(safeBlobUrl("blob:https://chat.bstein.dev/a"), "blob:https://chat.bstein.dev/a");
assert.equal(safeBlobUrl("blob:null/a"), "blob:null/a");
for (const bad of ["data:image/png;base64,a", "https://remote/a", "blob:data:image/png", "blob:has space", 4]) {
assert.equal(safeBlobUrl(bad), null);
}
assert.equal(safePreviewText("line one\nline two"), "line one\nline two");
assert.equal(safePreviewText("bad\u0000text"), null);
assert.equal(safePreviewText("abc", 2), null);
assert.equal(safePreviewText({}), null);
assert.equal(safeText(null, "fallback"), "fallback");
assert.equal(safeText(" \n ", "fallback"), "fallback");
assert.equal(safeText("token=private\u0000 next", "fallback"), "token=[redacted] next");
assert.equal(safeText("abcdef", "x", 4), "abc…");
assert.equal(safeEndpointPart("med_image1234", "med"), "med_image1234");
assert.throws(() => safeEndpointPart("../bad", "med"), TypeError);
});
test("upload validation is MIME, size, name, and authorization bounded", () => {
const cases = [["image/png", "image"], ["image/jpeg", "image"], ["image/webp", "image"],
["application/pdf", "document"], ["text/plain", "document"], ["text/markdown", "document"],
["audio/webm", "audio"], ["audio/wav", "audio"], ["audio/mpeg", "audio"], ["audio/ogg", "audio"]];
for (const [mime, kind] of cases) {
assert.equal(classifyMime(mime), kind);
assert.deepEqual(validateUpload({name: "safe.file", type: mime.toUpperCase(), size: 1}, "authorized"),
{ok: true, kind, file: {name: "safe.file", type: mime, size: 1}});
}
assert.equal(classifyMime("image/svg+xml"), null);
assert.equal(classifyMime(null), null);
assert.match(validateUpload({name: "x", type: "image/png", size: 1}, "requires_approval").reason, /approval/);
assert.match(validateUpload({name: "x", type: "image/png", size: 1}, "denied").reason, /denied/);
assert.match(validateUpload({name: "x.svg", type: "image/svg+xml", size: 1}, "authorized").reason, /not supported/);
for (const file of [{name: "x.png", type: "image/png", size: 0},
{name: "x.png", type: "image/png", size: 20 * 1024 * 1024 + 1},
{name: "../x.png", type: "image/png", size: 1}, {name: "", type: "image/png", size: 1},
{name: "x.png", type: "image/png", size: 1.2}]) {
assert.equal(validateUpload(file, "authorized").ok, false);
}
});
test("page normalization preserves safe media, variants, annotations, and transcript", () => {
const result = normalizeMultimodalPage(page(), IDENTITY, SCOPE);
assert.equal(result.rejected, 0);
assert.equal(result.media.length, 2);
assert.equal(result.media[0].previewUrl, "blob:https://chat.bstein.dev/123");
assert.equal(result.media[1].previewText, "Safe document\npreview");
assert.deepEqual(result.media[1].annotations, [{id: "ann_note1234", page: 2, note: "Check this paragraph"}]);
assert.equal(result.threads[0].variants[0].mediaId, "med_image1234");
assert.equal(result.transcript.text, "A clean transcript");
assert.equal(normalizeMultimodalPage(page({transcript: null}), IDENTITY, SCOPE).transcript, null);
});
test("envelope and records cannot cross tenant, user, project, or conversation scope", () => {
const invalidPages = [null, {}, page({schema: "old"}), page({api_version: "hux.v0"}),
page({identity: {...RAW_IDENTITY, tenant_ref: "tnt_ffffffffffffffff"}}),
page({identity: {...RAW_IDENTITY, user_ref: "usr_ffffffffffffffff"}}),
page({binding: {...RAW_SCOPE, project_id: "prj_other1234"}}),
page({binding: {...RAW_SCOPE, conversation_id: "conv_other1234"}}),
page({media: null}), page({media: Array(201).fill(media())}), page({threads: null}),
page({threads: Array(101).fill(thread())}), page({upload_authorization: "maybe"})];
for (const raw of invalidPages) assert.equal(normalizeMultimodalPage(raw, IDENTITY, SCOPE), null);
const invalidMedia = [null, media({schema: "old"}), media({id: "bad"}),
media({owner: "usr_ffffffffffffffff"}), media({project_id: "prj_other1234"}),
media({conversation_id: "conv_other1234"}), media({artifact_id: "bad"}),
media({artifact_version: 0}), media({kind: "video"}), media({file_name: ""}),
media({mime: "image/svg+xml"}), media({mime: "application/pdf"}), media({bytes: 0}),
media({annotations: null}), media({annotations: Array(501).fill({})}),
media({annotations: [null]}), media({annotations: [{id: "bad", page: null, note: "x"}]}),
media({annotations: [{id: "ann_note1234", page: 0, note: "x"}]}),
media({annotations: [{id: "ann_note1234", page: null, note: ""}]}),
media({preview_url: "data:image/png;base64,a"}), media({lineage: {}}),
media({preview_authorized: false}), media({preview_text: "text"}),
document({preview_text: "bad\u0000text"}), document({preview_authorized: false}),
document({preview_url: "blob:https://chat.bstein.dev/doc"}),
media({preview_url: null, preview_text: null, preview_authorized: false, bytes: "1200"}),
media({lineage: {media_id: "bad", artifact_id: "art_image1234", artifact_version: 1}}),
media({lineage: {media_id: "med_image1234", artifact_id: "art_image1234", artifact_version: 1}})];
for (const raw of invalidMedia) {
assert.equal(normalizeMultimodalPage(page({media: [raw]}), IDENTITY, SCOPE).media.length, 0);
}
});
test("lineage, thread cohesion, and transcript failures are withheld clearly", () => {
const child = media({id: "med_child1234", artifact_id: "art_child1234", artifact_version: 2,
lineage: {media_id: "med_image1234", artifact_id: "art_image1234", artifact_version: 1}});
assert.equal(normalizeMultimodalPage(page({media: [media(), child]}), IDENTITY, SCOPE).media.length, 2);
const orphan = {...child, lineage: {...child.lineage, artifact_version: 9}};
assert.equal(normalizeMultimodalPage(page({media: [media(), orphan]}), IDENTITY, SCOPE).media.length, 1);
const duplicate = normalizeMultimodalPage(page({media: [media(), media()]}), IDENTITY, SCOPE);
assert.equal(duplicate.media.length, 1);
assert.equal(duplicate.rejected, 1);
const badThreads = [null, thread({id: "bad"}), thread({owner: "usr_ffffffffffffffff"}),
thread({project_id: "prj_other1234"}), thread({conversation_id: "conv_other1234"}),
thread({prompt: ""}), thread({variants: null}), thread({variants: []}),
thread({variants: Array(201).fill(variant())}), thread({variants: [variant({id: "bad"})]}),
thread({variants: [variant({media_id: "med_missing1"})]}),
thread({variants: [variant({artifact_id: "art_other1234"})]}),
thread({variants: [variant({artifact_version: 2})]}),
thread({variants: [variant({parent_variant_id: "bad"})]}),
thread({variants: [variant({created_at: "today"})]}),
thread({variants: [variant(), variant()]}),
thread({variants: [variant({parent_variant_id: "var_missing12"})]})];
for (const raw of badThreads) {
assert.equal(normalizeMultimodalPage(page({threads: [raw]}), IDENTITY, SCOPE).threads.length, 0);
}
const second = variant({id: "var_second123", parent_variant_id: "var_first1234",
created_at: "2026-08-24T10:00:01Z"});
assert.equal(normalizeMultimodalPage(page({threads: [thread({variants: [variant(), second]})]}),
IDENTITY, SCOPE).threads[0].variants.length, 2);
const badTranscripts = [{}, transcript({schema: "old"}), transcript({owner: "usr_ffffffffffffffff"}),
transcript({project_id: "prj_other1234"}), transcript({conversation_id: "conv_other1234"}),
transcript({id: "bad"}), transcript({turn_id: "bad"}), transcript({revision: 0}),
transcript({language: ""}), transcript({text: ""}), transcript({finalized: "yes"})];
for (const raw of badTranscripts) {
const result = normalizeMultimodalPage(page({transcript: raw}), IDENTITY, SCOPE);
assert.equal(result.transcript, null);
assert.equal(result.rejected, 1);
}
});
test("edit, annotation, transcript, and capture intents are bounded and optimistic", () => {
const normalized = normalizeMultimodalPage(page(), IDENTITY, SCOPE);
const image = normalized.media[0];
const doc = normalized.media[1];
assert.deepEqual(buildImageEdit(image, SCOPE, " Make it warmer "), {
artifactId: image.artifactId, expectedVersion: 1, scope: SCOPE, mediaId: image.id,
instruction: "Make it warmer", lineage: {artifactId: image.artifactId, version: 1}});
assert.throws(() => buildImageEdit(doc, SCOPE, "edit"), TypeError);
assert.throws(() => buildImageEdit(image, SCOPE, ""), TypeError);
assert.deepEqual(buildAnnotation(doc, SCOPE, " Note this ", 2).page, 2);
assert.equal(buildAnnotation(doc, SCOPE, "Note", null).page, null);
for (const args of [[image, "note", 1], [doc, "", 1], [doc, "note", 0],
[doc, "note", 100001], [doc, "note", 1.2]]) assert.throws(() => buildAnnotation(args[0], SCOPE, args[1], args[2]), TypeError);
assert.equal(buildTranscriptCorrection(normalized.transcript, " corrected ", IDENTITY, SCOPE).correctedText, "corrected");
assert.throws(() => buildTranscriptCorrection({...normalized.transcript, finalized: false}, "x", IDENTITY, SCOPE), TypeError);
assert.throws(() => buildTranscriptCorrection(normalized.transcript, "", IDENTITY, SCOPE), TypeError);
assert.equal(captureAuthorization(null, null, IDENTITY), "deny");
assert.equal(captureAuthorization(policy("deny"), null, IDENTITY), "deny");
assert.equal(captureAuthorization(policy("allow"), null, IDENTITY), "approval_required");
assert.equal(captureAuthorization(policy("ask", "ask_first"), null, IDENTITY), "approval_required");
assert.deepEqual(buildCaptureIntent("camera", policy(), IDENTITY, SCOPE), {kind: "camera",
autonomyCapability: "network", requiredDecision: "ask", identity: IDENTITY, scope: SCOPE});
assert.equal(buildCaptureIntent("screen", policy(), IDENTITY, SCOPE).kind, "screen");
assert.throws(() => buildCaptureIntent("microphone", policy(), IDENTITY, SCOPE), TypeError);
assert.throws(() => buildCaptureIntent("camera", policy("deny"), IDENTITY, SCOPE), TypeError);
});
test("endpoint descriptions remain same-origin, scoped, default-off, and non-executing", () => {
const client = {apiVersion: "hux.v1", identity: IDENTITY,
endpoint(path) { assert.match(path, /^\/projects\/prj_[^/]+\/conversations\/conv_[^/]+\/multimodal/);
return `/hux/v1${path}`; }};
assert.equal(createMultimodalEndpointContract(client, SCOPE), null);
assert.equal(createMultimodalEndpointContract(client, SCOPE, FLAGS.slice(1)), null);
const contract = createMultimodalEndpointContract(client, SCOPE, FLAGS);
assert.equal(contract.page.path, "/hux/v1/projects/prj_test1234/conversations/conv_test1234/multimodal");
assert.equal(contract.beginUpload.method, "POST");
assert.match(contract.finalizeUpload("upl_upload123").path, /uploads\/upl_upload123\/finalize$/);
assert.match(contract.editImage("med_image1234").path, /media\/med_image1234\/edits$/);
assert.match(contract.annotate("med_image1234").path, /annotations$/);
assert.match(contract.correctTranscript("trn_voice1234").path, /corrections$/);
assert.match(contract.requestCapture("camera").path, /captures\/camera\/approvals$/);
assert.match(contract.requestCapture("screen").path, /captures\/screen\/approvals$/);
assert.match(contract.createVariant("thr_garden123").path, /threads\/thr_garden123\/variants$/);
for (const call of [() => contract.finalizeUpload("bad"), () => contract.editImage("bad"),
() => contract.correctTranscript("bad"), () => contract.requestCapture("audio"),
() => contract.createVariant("bad")]) assert.throws(call, TypeError);
assert.throws(() => createMultimodalEndpointContract({...client, apiVersion: "hux.v2"}, SCOPE, FLAGS), TypeError);
assert.throws(() => createMultimodalEndpointContract({...client, identity: {...IDENTITY, userRef: "bad"}}, SCOPE, FLAGS), TypeError);
assert.throws(() => createMultimodalEndpointContract(client, {...SCOPE, projectId: "bad"}, FLAGS), TypeError);
});

View File

@ -0,0 +1,94 @@
"""Executable and static quality gates for isolated HUX-07 multimodal chat."""
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MULTIMODAL = ROOT / "dockerfiles" / "hermes-webui-hux" / "multimodal"
def test_hux_multimodal_model_node_suite_and_coverage():
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-branches=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/multimodal/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/multimodal/security.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/multimodal/endpoints.ts",
"testing/tests/test_hermes_hux_ui_multimodal.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_multimodal_component_is_flagged_accessible_safe_and_inert():
component = (MULTIMODAL / "MultimodalChat.tsx").read_text(encoding="utf-8")
model = (MULTIMODAL / "model.ts").read_text(encoding="utf-8")
endpoints = (MULTIMODAL / "endpoints.ts").read_text(encoding="utf-8")
styles = (MULTIMODAL / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled || !page) return null" in component
assert "multimodalEnabled(props.flags)" in component
for flag in (
"FOUNDATION_FLAG",
"PROJECTS_FLAG",
"ARTIFACTS_FLAG",
"AUTONOMY_FLAG",
"MULTIMODAL_FLAG",
):
assert flag in model
assert 'aria-label="Multimodal chat"' in component
assert 'aria-live="polite"' in component
assert 'aria-label="Live media permissions"' in component
assert "Ask to use" in component and "Ask before uploading" in component
assert "Create linked variant" in component
assert "Correct transcript" in component
assert "Content is not executed" in component
assert "dangerouslySetInnerHTML" not in component
assert "<iframe" not in component and "<object" not in component and "<embed" not in component
assert "getUserMedia" not in component and "getDisplayMedia" not in component
assert "fetch(" not in endpoints and "axios" not in endpoints
assert '"PATCH"' not in endpoints and '"DELETE"' not in endpoints
assert ".hux-multimodal" in styles
assert "body" not in styles and ":root" not in styles
assert "prefers-reduced-motion" in styles
def test_hux_multimodal_sources_are_bounded_and_isolated():
sources = sorted(MULTIMODAL.glob("*"))
assert {path.name for path in sources} == {
"MultimodalChat.tsx",
"endpoints.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
assert "dockerfiles/hermes-webui-hux/multimodal" not in (
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
).read_text(encoding="utf-8")
def test_hux_multimodal_forbids_inline_and_executable_media():
security = (MULTIMODAL / "security.ts").read_text(encoding="utf-8")
component = (MULTIMODAL / "MultimodalChat.tsx").read_text(encoding="utf-8")
assert "image/svg+xml" not in security
assert "text/html" not in security
assert "data:" not in component
assert "URL.createObjectURL" not in component
assert "blob:" in security

View File

@ -0,0 +1,413 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createOnboardingClient,
OnboardingContractError,
} from "../../dockerfiles/hermes-webui-hux/onboarding/client.ts";
import {
FOUNDATION_FLAG,
FRIENDLY_MODES_FLAG,
ONBOARDING_FLAG,
PRIVACY_FLAG,
PROJECTS_FLAG,
buildActionIntent,
normalizeDecisionState,
normalizeEvaluation,
normalizeState,
normalizeSuggestion,
onboardingEnabled,
} from "../../dockerfiles/hermes-webui-hux/onboarding/model.ts";
import {
asRecord,
exactKeys,
isId,
isSafeText,
isUtc,
normalizeIdentity,
normalizeScope,
normalizeTrigger,
sameIdentity,
sameTrigger,
scopedSuggestionPath,
} from "../../dockerfiles/hermes-webui-hux/onboarding/security.ts";
const IDENTITY = Object.freeze({
tenantRef: "tnt_0123456789abcdef",
userRef: "usr_0123456789abcdef",
surface: "chat",
});
const RAW_IDENTITY = Object.freeze({
tenant_ref: IDENTITY.tenantRef,
user_ref: IDENTITY.userRef,
surface: IDENTITY.surface,
});
const SCOPE = Object.freeze({
sessionId: "ses_alpha1234",
conversationId: "conv_alpha1234",
trigger: {surface: "chat", context: "after_approval"},
});
const FLAGS = [
FOUNDATION_FLAG, PROJECTS_FLAG, FRIENDLY_MODES_FLAG, ONBOARDING_FLAG,
PRIVACY_FLAG,
];
function suggestion(extra = {}) {
return {
schema: "hux.suggestion.v1",
id: "sug_alpha1234",
kind: "workflow",
trigger: SCOPE.trigger,
title: "Run this every week?",
body: "Hermes can prepare the same report each week after you confirm it.",
action: {type: "start_workflow", payload: {intent: "schedule_weekly"}},
priority: 35,
suppression: {
dismissable: true,
max_shows: 2,
cooldown_seconds: 86400,
never_again_supported: true,
},
...extra,
};
}
function state(extra = {}) {
return {
schema: "hux.suggestion_state.v1",
owner: IDENTITY.userRef,
suggestion_id: "sug_alpha1234",
shows: 1,
last_shown_at: "2026-08-24T10:00:00Z",
never_again: false,
...extra,
};
}
function evaluation(extra = {}) {
return {
schema: "hux.suggestion_evaluation.v1",
api_version: "hux.v1",
identity: RAW_IDENTITY,
session_id: SCOPE.sessionId,
conversation_id: SCOPE.conversationId,
trigger: SCOPE.trigger,
privacy: {
schema: "hux.onboarding_privacy.v1",
no_store: false,
memory_suggestions_allowed: true,
},
claim_id: "clm_alpha1234",
issued_at: "2026-08-24T10:00:01Z",
suggestion: suggestion(),
state: state(),
...extra,
};
}
function decisionEnvelope(item, decision = "dismiss", extra = {}) {
const timestamp = "2026-08-24T10:01:00Z";
return {
schema: "hux.suggestion_decision.v1",
api_version: "hux.v1",
identity: RAW_IDENTITY,
session_id: SCOPE.sessionId,
conversation_id: SCOPE.conversationId,
claim_id: "clm_alpha1234",
suggestion_id: "sug_alpha1234",
state: {
...state(),
...(decision === "acted" ? {acted_at: timestamp} : {dismissed_at: timestamp}),
never_again: decision === "never_again",
},
...extra,
...(item ? {state: item} : {}),
};
}
function harness(responses, flags = FLAGS) {
const calls = [];
const enabled = new Set(flags);
const foundation = {
apiVersion: "hux.v1",
identity: IDENTITY,
enabled: (flag) => enabled.has(flag),
endpoint: (path) => `/hux/v1${path}`,
};
const fetcher = async (url, init) => {
calls.push({url, init});
const response = responses.shift();
if (!response) throw new Error("unexpected request");
return {
ok: response.ok ?? true,
status: response.status ?? 200,
json: async () => response.body,
};
};
return {calls, foundation,
client: createOnboardingClient({client: foundation, fetcher})};
}
test("HUX-09 is default-off and requires all four dependencies", () => {
assert.equal(onboardingEnabled(), false);
assert.equal(onboardingEnabled([]), false);
for (const flag of FLAGS.slice(0, 4)) {
assert.equal(onboardingEnabled(FLAGS.slice(0, 4).filter((item) => item !== flag)), false);
}
assert.equal(onboardingEnabled(FLAGS.slice(0, 4)), true);
});
test("security primitives bind exact identity, session and trigger", () => {
assert.deepEqual(asRecord({a: 1}), {a: 1});
assert.equal(asRecord(null), null);
assert.equal(asRecord([]), null);
assert.equal(exactKeys({a: 1, b: 2}, ["a"], ["b"]), true);
assert.equal(exactKeys({a: 1, x: 2}, ["a"]), false);
assert.equal(isId("sug_alpha1234", "sug"), true);
assert.equal(isId("bad", "sug"), false);
assert.equal(isId("clm_alpha1234", "sug"), false);
assert.equal(isUtc("2026-08-24T10:00:00Z"), true);
assert.equal(isUtc("2026-99-99T10:00:00Z"), false);
assert.equal(isSafeText("hello", 1, 10), true);
assert.equal(isSafeText("bad\u0000", 1, 10), false);
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
for (const raw of [null, [], {}, {...RAW_IDENTITY, extra: true},
{...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"},
{...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(raw), null);
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
assert.deepEqual(normalizeTrigger(SCOPE.trigger), SCOPE.trigger);
for (const raw of [null, {}, {...SCOPE.trigger, extra: true},
{...SCOPE.trigger, surface: "browser"}, {...SCOPE.trigger, context: "often"}])
assert.equal(normalizeTrigger(raw), null);
assert.equal(sameTrigger(SCOPE.trigger, SCOPE.trigger), true);
assert.equal(sameTrigger(SCOPE.trigger, {...SCOPE.trigger, context: "idle"}), false);
assert.deepEqual(normalizeScope(SCOPE, IDENTITY), SCOPE);
for (const raw of [{...SCOPE, extra: true}, {...SCOPE, sessionId: "bad"},
{...SCOPE, conversationId: "bad"},
{...SCOPE, trigger: {...SCOPE.trigger, surface: "worker"}}])
assert.equal(normalizeScope(raw, IDENTITY), null);
assert.equal(scopedSuggestionPath("/onboarding/suggestions/", "sug_alpha1234"),
"/onboarding/suggestions/sug_alpha1234/decisions");
for (const [base, id] of [["//evil", "sug_alpha1234"], ["/x?y", "sug_alpha1234"],
["/../x", "sug_alpha1234"], ["/safe", "bad"]])
assert.throws(() => scopedSuggestionPath(base, id), TypeError);
});
test("suggestion schema normalizes all four contextual actions", () => {
const workflow = normalizeSuggestion(suggestion(), SCOPE.trigger);
assert.equal(workflow.action.payload.intent, "schedule_weekly");
assert.equal(workflow.suppression.cooldownSeconds, 86400);
const save = normalizeSuggestion(suggestion({
action: {type: "start_workflow", payload: {intent: "save_reusable"}},
}), SCOPE.trigger);
assert.equal(save.action.payload.intent, "save_reusable");
const project = normalizeSuggestion(suggestion({kind: "project",
action: {type: "create_project", payload: {prefill_title: "Launch plan"}}}), SCOPE.trigger);
assert.equal(project.action.payload.prefill_title, "Launch plan");
const memory = normalizeSuggestion(suggestion({kind: "feature",
action: {type: "open_memory", payload: {view: "suggestions"}}}), SCOPE.trigger);
assert.equal(memory.action.type, "open_memory");
const mode = normalizeSuggestion(suggestion({kind: "feature",
action: {type: "open_mode", payload: {mode: "research"}}}), SCOPE.trigger);
assert.equal(mode.action.payload.mode, "research");
const artifacts = normalizeSuggestion(suggestion({kind: "feature",
action: {type: "open_artifacts", payload: {view: "current"}}}), SCOPE.trigger);
assert.equal(artifacts.action.type, "open_artifacts");
const tip = normalizeSuggestion(suggestion({kind: "tip", action: undefined}), SCOPE.trigger);
assert.equal(tip.action.type, "none");
assert.deepEqual(normalizeSuggestion(suggestion({kind: "project",
action: {type: "create_project"}}), SCOPE.trigger).action.payload, {});
assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature",
action: {type: "open_mode"}}), SCOPE.trigger).action.payload, {});
assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature",
action: {type: "open_memory"}}), SCOPE.trigger).action.payload, {});
assert.deepEqual(normalizeSuggestion(suggestion({kind: "feature",
action: {type: "open_artifacts"}}), SCOPE.trigger).action.payload, {});
});
test("malformed, mismatched, or pushy suggestions fail closed", () => {
const invalid = [
null, {...suggestion(), extra: true}, {...suggestion(), schema: "hux.suggestion.v0"},
{...suggestion(), id: "bad"}, {...suggestion(), kind: "ad"},
{...suggestion(), title: ""}, {...suggestion(), title: "x".repeat(81)},
{...suggestion(), body: "bad\u0000"}, {...suggestion(), priority: 101},
{...suggestion(), trigger: {...SCOPE.trigger, context: "idle"}},
{...suggestion(), suppression: null},
{...suggestion(), suppression: {...suggestion().suppression, dismissable: false}},
{...suggestion(), suppression: {...suggestion().suppression, max_shows: 6}},
{...suggestion(), suppression: {...suggestion().suppression, cooldown_seconds: 30}},
{...suggestion(), suppression: {...suggestion().suppression, never_again_supported: false}},
{...suggestion(), action: null},
{...suggestion(), action: {type: "unknown"}},
{...suggestion(), action: {type: "start_workflow", payload: {intent: "daily"}}},
{...suggestion(), action: {type: "start_workflow", payload: {intent: "schedule_weekly", extra: 1}}},
{...suggestion(), action: {type: "create_project", payload: {prefill_title: ""}}},
{...suggestion(), action: {type: "create_project", payload: {prefill_title: "x".repeat(121)}}},
{...suggestion(), action: {type: "create_project", payload: {extra: true}}},
{...suggestion(), action: {type: "open_mode", payload: {mode: "turbo"}}},
{...suggestion(), action: {type: "open_mode", payload: {extra: true}}},
{...suggestion(), action: {type: "open_memory", payload: {view: "all"}}},
{...suggestion(), action: {type: "open_memory", payload: {extra: true}}},
{...suggestion(), action: {type: "open_artifacts", payload: {view: "all"}}},
{...suggestion(), action: {type: "open_artifacts", payload: {extra: true}}},
{...suggestion(), action: {type: "none", payload: {surprise: true}}},
{...suggestion(), kind: "project"},
];
invalid.forEach((raw) => assert.equal(normalizeSuggestion(raw, SCOPE.trigger), null));
});
test("eligible state is owner-bound and inside the server rate limit", () => {
const item = normalizeSuggestion(suggestion(), SCOPE.trigger);
assert.equal(normalizeState(state(), item, IDENTITY.userRef).shows, 1);
const complete = normalizeState(state({dismissed_at: "2026-08-24T10:01:00Z",
acted_at: "2026-08-24T10:02:00Z"}), item, IDENTITY.userRef);
assert.equal(complete.dismissedAt, "2026-08-24T10:01:00Z");
assert.equal(complete.actedAt, "2026-08-24T10:02:00Z");
const invalid = [null, {...state(), extra: true}, {...state(), schema: "old"},
{...state(), owner: "usr_aaaaaaaaaaaaaaaa"}, {...state(), suggestion_id: "sug_other1234"},
{...state(), shows: 0}, {...state(), shows: 3}, {...state(), last_shown_at: "today"},
{...state(), never_again: true}, {...state(), dismissed_at: "today"},
{...state(), acted_at: "today"}];
invalid.forEach((raw) => assert.equal(normalizeState(raw, item, IDENTITY.userRef), null));
});
test("evaluation binds identity, session, trigger, privacy and one claim", () => {
const item = normalizeEvaluation(evaluation(), IDENTITY, SCOPE, true);
assert.equal(item.claimId, "clm_alpha1234");
assert.equal(item.state.owner, IDENTITY.userRef);
assert.equal(normalizeEvaluation(evaluation({privacy: {
schema: "hux.onboarding_privacy.v1", no_store: true,
memory_suggestions_allowed: false}, claim_id: null, suggestion: null, state: null}),
IDENTITY, SCOPE, true), null);
assert.equal(normalizeEvaluation(evaluation({suggestion: null, claim_id: null, state: null}),
IDENTITY, SCOPE, true), null);
const memoryRaw = evaluation({suggestion: suggestion({kind: "feature",
action: {type: "open_memory", payload: {view: "suggestions"}}})});
assert.equal(normalizeEvaluation(memoryRaw, IDENTITY, SCOPE, false), null);
assert.equal(normalizeEvaluation({...memoryRaw, privacy: {...memoryRaw.privacy,
memory_suggestions_allowed: false}}, IDENTITY, SCOPE, true), null);
assert.ok(normalizeEvaluation(memoryRaw, IDENTITY, SCOPE, true));
const invalid = [null, {...evaluation(), extra: true}, {...evaluation(), schema: "old"},
{...evaluation(), api_version: "hux.v0"},
{...evaluation(), identity: {...RAW_IDENTITY, surface: "worker"}},
{...evaluation(), session_id: "ses_other1234"},
{...evaluation(), conversation_id: "conv_other1234"},
{...evaluation(), trigger: {...SCOPE.trigger, context: "idle"}},
{...evaluation(), privacy: null},
{...evaluation(), privacy: {...evaluation().privacy, extra: true}},
{...evaluation(), privacy: {...evaluation().privacy, no_store: "no"}},
{...evaluation(), claim_id: "bad"}, {...evaluation(), issued_at: "today"},
{...evaluation(), state: {...state(), owner: "usr_aaaaaaaaaaaaaaaa"}}];
invalid.forEach((raw) => assert.equal(normalizeEvaluation(raw, IDENTITY, SCOPE, true), null));
});
test("actions bridge only validated HUX-03/HUX-06 interfaces", () => {
const make = (change) => normalizeEvaluation(evaluation({
suggestion: suggestion(change),
}), IDENTITY, SCOPE, true);
assert.deepEqual(buildActionIntent(make({})),
{type: "start_workflow", intent: "schedule_weekly"});
assert.deepEqual(buildActionIntent(make({kind: "project", action: {
type: "create_project", payload: {prefill_title: "Launch"}}})),
{type: "create_project", conversationId: SCOPE.conversationId, prefillTitle: "Launch"});
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
type: "open_mode", payload: {mode: "fast"}}})), {type: "open_mode", mode: "fast"});
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
type: "open_mode"}})), {type: "open_mode"});
assert.deepEqual(buildActionIntent(make({kind: "project", action: {
type: "create_project"}})),
{type: "create_project", conversationId: SCOPE.conversationId});
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
type: "open_memory"}})), {type: "open_memory", view: "suggestions"});
assert.deepEqual(buildActionIntent(make({kind: "feature", action: {
type: "open_artifacts"}})), {type: "open_artifacts", view: "current"});
assert.equal(buildActionIntent(make({kind: "tip", action: {type: "none"}})), null);
});
test("decision states preserve shows and prove the chosen outcome", () => {
const item = normalizeEvaluation(evaluation(), IDENTITY, SCOPE, true);
for (const decision of ["dismiss", "never_again", "acted"]) {
const raw = decisionEnvelope(null, decision).state;
const normalized = normalizeDecisionState(raw, item, decision);
assert.ok(normalized);
assert.equal(normalized.neverAgain, decision === "never_again");
}
for (const raw of [null, {...state(), extra: true}, {...state(), shows: 2},
{...state(), last_shown_at: "2026-08-24T11:00:00Z"},
{...state(), never_again: "yes"}, {...state(), dismissed_at: "today"},
{...state(), acted_at: "today"}, {...state(), acted_at: "2026-08-24T10:01:00Z"}])
assert.equal(normalizeDecisionState(raw, item, "dismiss"), null);
assert.ok(normalizeDecisionState({...state(), dismissed_at: "2026-08-24T10:01:00Z",
acted_at: "2026-08-24T10:02:00Z"}, item, "dismiss"));
});
test("client evaluates exact triggers with no-store same-origin requests", async () => {
const current = harness([{body: evaluation()}]);
assert.equal(current.client.enabled(), true);
const item = await current.client.evaluate(SCOPE);
assert.equal(item.suggestion.title, "Run this every week?");
assert.equal(current.calls[0].url, "/hux/v1/onboarding/evaluate");
assert.equal(current.calls[0].init.method, "POST");
assert.equal(current.calls[0].init.cache, "no-store");
assert.equal(current.calls[0].init.credentials, "same-origin");
assert.deepEqual(JSON.parse(current.calls[0].init.body), {
schema: "hux.suggestion_evaluation_request.v1", session_id: SCOPE.sessionId,
conversation_id: SCOPE.conversationId, trigger: SCOPE.trigger,
});
const replay = harness([{body: evaluation()}, {body: evaluation()}]);
assert.ok(await replay.client.evaluate(SCOPE));
assert.equal(await replay.client.evaluate(SCOPE), null);
const empty = harness([{status: 204}, {status: 404}]);
assert.equal(await empty.client.evaluate(SCOPE), null);
assert.equal(await empty.client.evaluate(SCOPE), null);
});
test("client persists explicit decisions without performing suggested work", async () => {
for (const decision of ["dismiss", "never_again", "acted"]) {
const current = harness([{body: evaluation()}, {body: decisionEnvelope(null, decision)}]);
const item = await current.client.evaluate(SCOPE);
const after = await current.client.decide(item, decision);
assert.equal(after.neverAgain, decision === "never_again");
assert.equal(current.calls[1].url,
"/hux/v1/onboarding/suggestions/sug_alpha1234/decisions");
assert.deepEqual(JSON.parse(current.calls[1].init.body), {
schema: "hux.suggestion_decision_request.v1", session_id: SCOPE.sessionId,
conversation_id: SCOPE.conversationId, claim_id: item.claimId,
suggestion_id: item.suggestion.id, expected_shows: 1, decision,
});
await assert.rejects(current.client.decide(item, decision), /not bound/);
}
});
test("client rejects disabled, malformed, cross-scope, stale, and unissued data", async () => {
const disabled = harness([], [FOUNDATION_FLAG, PROJECTS_FLAG]);
assert.equal(disabled.client.enabled(), false);
await assert.rejects(disabled.client.evaluate(SCOPE), /not enabled/);
disabled.foundation.apiVersion = "hux.v0";
await assert.rejects(disabled.client.evaluate(SCOPE), /not enabled/);
const invalidScope = harness([]);
await assert.rejects(invalidScope.client.evaluate({...SCOPE, sessionId: "bad"}), /outside/);
await assert.rejects(invalidScope.client.decide({claimId: "clm_fake1234"}, "dismiss"), /not bound/);
await assert.rejects(invalidScope.client.decide({}, "delete"), /not bound/);
for (const response of [{ok: false, status: 409}, {ok: false, status: 503}]) {
const current = harness([response]);
await assert.rejects(current.client.evaluate(SCOPE), OnboardingContractError);
}
const malformed = harness([{body: {}}]);
assert.equal(await malformed.client.evaluate(SCOPE), null);
const wrongDecision = harness([{body: evaluation()}, {body: decisionEnvelope(null, "dismiss", {
identity: {...RAW_IDENTITY, surface: "worker"}})}]);
const item = await wrongDecision.client.evaluate(SCOPE);
await assert.rejects(wrongDecision.client.decide(item, "dismiss"), /crossed/);
const missing = harness([{body: evaluation()}, {status: 204}]);
await assert.rejects(missing.client.decide(await missing.client.evaluate(SCOPE), "dismiss"), /crossed/);
assert.throws(() => createOnboardingClient({client: null, fetcher: async () => ({})}), /requires/);
assert.throws(() => createOnboardingClient({client: {...invalidScope.foundation,
identity: {...IDENTITY, tenantRef: "bad"}}, fetcher: async () => ({})}), /identity/);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = undefined;
assert.throws(() => createOnboardingClient({client: invalidScope.foundation}), /requires/);
} finally { globalThis.fetch = originalFetch; }
});

View File

@ -0,0 +1,117 @@
"""Executable and static quality gates for the isolated HUX-09 frontend."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ONBOARDING = ROOT / "dockerfiles" / "hermes-webui-hux" / "onboarding"
def test_hux_onboarding_node_suite_and_coverage():
"""Suggestion validation and transport remain above the repository gate."""
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-branches=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/onboarding/client.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/onboarding/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/onboarding/security.ts",
"testing/tests/test_hermes_hux_ui_onboarding.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_onboarding_matches_schema_and_is_inert():
"""HUX-09 consumes the shared schema without entering a live image."""
schema = json.loads(
(ROOT / "services/hermes/contracts/hux/suggestion.schema.json").read_text(
encoding="utf-8"
)
)
model = (ONBOARDING / "model.ts").read_text(encoding="utf-8")
for value in schema["$defs"]["suggestion"]["properties"]["kind"]["enum"]:
assert f'"{value}"' in model
for value in schema["$defs"]["suggestion"]["properties"]["action"][
"properties"
]["type"]["enum"]:
assert f'"{value}"' in model
for value in schema["$defs"]["suggestion"]["properties"]["trigger"][
"properties"
]["context"]["enum"]:
assert f'"{value}"' in (ONBOARDING / "security.ts").read_text(
encoding="utf-8"
)
assert "dockerfiles/hermes-webui-hux/onboarding" not in (
ROOT / "dockerfiles/Dockerfile.hermes-webui"
).read_text(encoding="utf-8")
def test_hux_onboarding_ui_is_accessible_restrained_and_not_persistent():
"""The card stays optional, dismissible, scoped and keyboard accessible."""
component = (ONBOARDING / "OnboardingSuggestions.tsx").read_text(
encoding="utf-8"
)
client = (ONBOARDING / "client.ts").read_text(encoding="utf-8")
contracts = (ONBOARDING / "contracts.ts").read_text(encoding="utf-8")
styles = (ONBOARDING / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled || !evaluation" in component
assert "Optional next step" in component
assert "Not now" in component and "Don&apos;t suggest this again" in component
assert "Why am I seeing this?" in component
assert 'role="status"' in component and 'aria-live="polite"' in component
assert 'role="alert"' in component
assert "dangerouslySetInnerHTML" not in component
assert all(
token not in client
for token in ("localStorage", "sessionStorage", "indexedDB", "document.cookie")
)
assert 'credentials: "same-origin"' in client and 'cache: "no-store"' in client
assert "seenClaims" in client and "activeClaims" in client
assert "MUST NOT be accepted" in contracts and "single-use claim_id" in contracts
assert "never_again" in contracts and "cooldown_seconds" in contracts
assert ".hux-onboarding" in styles
assert "body" not in styles and ":root" not in styles
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
def test_hux_onboarding_sources_are_bounded_and_endpoint_only():
"""The slice defines backend needs but cannot mutate runtime wiring."""
sources = sorted(ONBOARDING.glob("*"))
assert {path.name for path in sources} == {
"OnboardingSuggestions.tsx",
"client.ts",
"contracts.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
contracts = (ONBOARDING / "contracts.ts").read_text(encoding="utf-8")
assert "POST /hux/v1/onboarding/evaluate" in contracts
assert (
"POST /hux/v1/onboarding/suggestions/{sug_id}/decisions" in contracts
)
assert "MUST NOT write suppression state" in contracts
assert "A decision never performs" in contracts

View File

@ -0,0 +1,417 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createOrganizationClient,
OrganizationContractError,
} from "../../dockerfiles/hermes-webui-hux/organization/client.ts";
import {
FOUNDATION_FLAG,
PROJECTS_FLAG,
attachmentViews,
branchLineage,
normalizeConversation,
normalizeProject,
normalizeSnapshot,
preservesConversation,
projectsEnabled,
} from "../../dockerfiles/hermes-webui-hux/organization/model.ts";
import {
boundedLimit,
isOpaqueId,
isUtc,
normalizeIdentity,
safeCursor,
safeTags,
safeText,
sameIdentity,
scopedPath,
} from "../../dockerfiles/hermes-webui-hux/organization/security.ts";
const IDENTITY = Object.freeze({
tenantRef: "tnt_0123456789abcdef",
userRef: "usr_0123456789abcdef",
surface: "chat",
});
const RAW_IDENTITY = Object.freeze({
tenant_ref: IDENTITY.tenantRef,
user_ref: IDENTITY.userRef,
surface: IDENTITY.surface,
});
const PROJECT_ID = "prj_alpha1234";
function project(extra = {}) {
return {
schema: "hux.project.v1",
id: PROJECT_ID,
owner: IDENTITY.userRef,
name: "Kitchen work",
description: "Plans",
tags: ["home"],
pinned: true,
archived: false,
default_mode: "research",
created_at: "2026-08-20T09:00:00Z",
updated_at: "2026-08-24T09:00:00Z",
...extra,
};
}
function conversation(extra = {}) {
return {
schema: "hux.conversation.v1",
id: "conv_child1234",
owner: IDENTITY.userRef,
project_id: PROJECT_ID,
title: "Compare suppliers",
tags: ["suppliers"],
pinned: false,
archived: false,
mode: "research",
branch: {
parent_conversation_id: "conv_parent1234",
branch_point_message_id: "msg-12",
},
artifact_ids: ["art_quote1234", "art_plan1234"],
last_message_at: "2026-08-24T09:00:01Z",
created_at: "2026-08-20T09:00:00Z",
updated_at: "2026-08-24T09:00:01Z",
...extra,
};
}
function envelope(schema, items, extra = {}) {
return {schema, api_version: "hux.v1", identity: RAW_IDENTITY,
items, next_cursor: "next_page", total: items.length, ...extra};
}
function mutation(schema, item, extra = {}) {
return {schema, api_version: "hux.v1", identity: RAW_IDENTITY, item, ...extra};
}
function harness(responses, flags = [FOUNDATION_FLAG, PROJECTS_FLAG]) {
const calls = [];
const enabled = new Set(flags);
const foundation = {
apiVersion: "hux.v1",
identity: IDENTITY,
enabled: (flag) => enabled.has(flag),
endpoint: (path) => `/hux/v1${path}`,
};
const fetcher = async (url, init) => {
calls.push({url, init});
const response = responses.shift();
if (!response) throw new Error("unexpected request");
return {ok: response.ok ?? true, status: response.status ?? 200,
json: async () => response.body};
};
return {calls, client: createOrganizationClient({client: foundation, fetcher}), foundation};
}
test("HUX-03 is default-off and requires the foundation", () => {
assert.equal(projectsEnabled(), false);
assert.equal(projectsEnabled([]), false);
assert.equal(projectsEnabled([PROJECTS_FLAG]), false);
assert.equal(projectsEnabled([FOUNDATION_FLAG]), false);
assert.equal(projectsEnabled([FOUNDATION_FLAG, PROJECTS_FLAG]), true);
});
test("security primitives reject scope/path/query confusion", () => {
assert.equal(isOpaqueId("conv_abcd", "conv"), true);
assert.equal(isOpaqueId("prj_abcd", "conv"), false);
assert.equal(isOpaqueId("bad"), false);
assert.equal(isOpaqueId(4), false);
assert.equal(isUtc("2026-08-24T09:00:00.123Z"), true);
assert.equal(isUtc("2026-99-24T09:00:00Z"), false);
assert.equal(isUtc(4), false);
assert.equal(safeText({}, "fallback", 5), "fallback");
assert.equal(safeText(" \u0000 ", "fallback", 5), "fallback");
assert.equal(safeText("abcdef", "fallback", 4), "abc…");
assert.equal(safeText("okay", "fallback", 8), "okay");
assert.deepEqual(safeTags(["one", "two-2"]), ["one", "two-2"]);
assert.equal(safeTags("one"), null);
assert.equal(safeTags(Array(33).fill("x")), null);
assert.equal(safeTags(["Bad"]), null);
assert.equal(safeTags(["same", "same"]), null);
assert.equal(safeTags([4]), null);
assert.equal(safeCursor(undefined), null);
assert.equal(safeCursor(""), null);
assert.equal(safeCursor("next_page"), "next_page");
assert.equal(safeCursor("bad/cursor"), undefined);
assert.equal(boundedLimit(22), 22);
assert.equal(boundedLimit(200), 100);
assert.equal(boundedLimit(0), 40);
assert.equal(boundedLimit(undefined, 20), 20);
assert.equal(scopedPath("/projects/", PROJECT_ID), `/projects/${PROJECT_ID}`);
for (const base of ["https://evil", "//evil", "/hux?x", "/hux\\x", "/../x"]) {
assert.throws(() => scopedPath(base, PROJECT_ID), /same-origin/);
}
assert.throws(() => scopedPath("/projects", "../bad"), /identifier/);
});
test("identity normalization is exact and includes the current surface", () => {
const normalized = normalizeIdentity(RAW_IDENTITY);
assert.deepEqual(normalized, IDENTITY);
assert.equal(sameIdentity(normalized, IDENTITY), true);
assert.equal(sameIdentity(normalized, {...IDENTITY, surface: "worker"}), false);
assert.equal(sameIdentity(normalized, {...IDENTITY, userRef: "usr_aaaaaaaaaaaaaaaa"}), false);
assert.equal(sameIdentity(normalized, {...IDENTITY, tenantRef: "tnt_aaaaaaaaaaaaaaaa"}), false);
assert.equal(normalizeIdentity(null), null);
assert.equal(normalizeIdentity({...RAW_IDENTITY, tenant_ref: "raw"}), null);
assert.equal(normalizeIdentity({...RAW_IDENTITY, user_ref: "raw"}), null);
assert.equal(normalizeIdentity({...RAW_IDENTITY, surface: "browser"}), null);
});
test("project normalization follows project.schema and owner binding", () => {
const item = normalizeProject(project(), IDENTITY.userRef);
assert.equal(item.name, "Kitchen work");
assert.equal(item.description, "Plans");
const invalid = [
{schema: "old"}, {id: "bad"}, {owner: "usr_aaaaaaaaaaaaaaaa"},
{tags: ["Bad"]}, {pinned: "yes"}, {archived: 0}, {created_at: "today"},
{updated_at: "today"}, {default_mode: "turbo"}, {default_mode: 7},
{name: ""},
];
invalid.forEach((change) => assert.equal(normalizeProject(project(change), IDENTITY.userRef), null));
assert.equal(normalizeProject(project({description: {}}), IDENTITY.userRef).description, "");
assert.equal(normalizeProject(project({default_mode: undefined}), IDENTITY.userRef).id, PROJECT_ID);
});
test("conversation normalization binds project, lineage, and attachments", () => {
const projects = new Set([PROJECT_ID]);
const item = normalizeConversation(conversation(), IDENTITY.userRef, projects);
assert.deepEqual(item.artifactIds, ["art_quote1234", "art_plan1234"]);
assert.equal(item.branch.parentConversationId, "conv_parent1234");
assert.equal(item.lastMessageAt, "2026-08-24T09:00:01Z");
assert.equal(normalizeConversation(conversation({project_id: undefined}), IDENTITY.userRef, projects).projectId, null);
assert.equal(normalizeConversation(conversation({branch: undefined}), IDENTITY.userRef, projects).branch, null);
assert.equal(normalizeConversation(conversation({last_message_at: undefined, mode: undefined}),
IDENTITY.userRef, projects).lastMessageAt, null);
const invalid = [
{schema: "old"}, {id: "bad"}, {owner: "usr_aaaaaaaaaaaaaaaa"}, {tags: ["Bad"]},
{branch: null}, {branch: []}, {branch: {parent_conversation_id: "bad", branch_point_message_id: "x"}},
{branch: {parent_conversation_id: "conv_parent1234", branch_point_message_id: 3}},
{branch: {parent_conversation_id: "conv_parent1234", branch_point_message_id: ""}},
{branch: {parent_conversation_id: "conv_child1234", branch_point_message_id: "x"}},
{pinned: "no"}, {archived: "no"}, {artifact_ids: null},
{artifact_ids: ["bad"]}, {artifact_ids: ["art_same1234", "art_same1234"]},
{artifact_ids: Array.from({length: 501}, (_, index) => `art_${String(index).padStart(4, "0")}`)},
{created_at: "today"}, {updated_at: "today"}, {last_message_at: "today"},
{mode: "turbo"}, {mode: 7}, {project_id: "prj_other1234"}, {project_id: "bad"},
{title: ""},
];
invalid.forEach((change) => assert.equal(
normalizeConversation(conversation(change), IDENTITY.userRef, projects), null));
});
test("snapshot collections are bounded and cross-scope items are counted", () => {
const projects = Array.from({length: 101}, (_, index) => project({id: `prj_${String(index).padStart(4, "0")}`}));
const conversations = Array.from({length: 501}, (_, index) => conversation({
id: `conv_${String(index).padStart(4, "0")}`,
project_id: index === 500 ? "prj_0100" : `prj_${String(index % 100).padStart(4, "0")}`,
branch: undefined,
}));
const result = normalizeSnapshot(projects, conversations, IDENTITY.userRef);
assert.equal(result.projects.length, 100);
assert.equal(result.conversations.length, 500);
assert.equal(result.rejected, 2);
const rejected = normalizeSnapshot([project({owner: "usr_aaaaaaaaaaaaaaaa"})],
[conversation({owner: "usr_aaaaaaaaaaaaaaaa"})], IDENTITY.userRef);
assert.equal(rejected.rejected, 2);
const duplicated = normalizeSnapshot([project(), project()], [conversation(), conversation()], IDENTITY.userRef);
assert.equal(duplicated.projects.length, 1);
assert.equal(duplicated.conversations.length, 1);
assert.equal(duplicated.rejected, 2);
});
test("attachments never disappear when their metadata is unavailable", () => {
const item = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID]));
const views = attachmentViews(item, [
{id: "art_quote1234", title: " Quote \u0000", kind: "document"},
{id: "bad", title: "Bad", kind: "bad"},
]);
assert.equal(views.length, item.artifactIds.length);
assert.deepEqual(views.map((view) => view.missing), [false, true]);
assert.equal(views[0].title, "Quote");
assert.equal(views[1].title, "Attachment details unavailable");
assert.equal(attachmentViews(item, [{id: "art_quote1234", title: {}, kind: {}}])[0].title,
"Untitled artifact");
});
test("lineage is visualizable even when an ancestor is absent or cyclic", () => {
const child = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID]));
const parent = normalizeConversation(conversation({id: "conv_parent1234", title: "Root",
branch: undefined}), IDENTITY.userRef, new Set([PROJECT_ID]));
const complete = branchLineage(child.id, [child, parent]);
assert.deepEqual(complete.map((node) => node.title), ["Root", "Compare suppliers"]);
assert.deepEqual(complete.map((node) => node.depth), [0, 1]);
const missing = branchLineage(child.id, [child]);
assert.equal(missing[0].missing, true);
assert.equal(missing[1].branchPointMessageId, "msg-12");
const cyclicParent = {...parent, branch: {parentConversationId: child.id,
branchPointMessageId: "msg-cycle"}};
const cycle = branchLineage(child.id, [child, cyclicParent]);
assert.equal(cycle[0].cycle, true);
assert.deepEqual(branchLineage("bad", [child]), []);
});
test("moves preserve stable resume identity, branch, and ordered attachments", () => {
const before = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID]));
assert.equal(preservesConversation(before, {...before, projectId: null, title: "Renamed"}), true);
assert.equal(preservesConversation(before, {...before, id: "conv_other1234"}), false);
assert.equal(preservesConversation(before, {...before, owner: "usr_aaaaaaaaaaaaaaaa"}), false);
assert.equal(preservesConversation(before, {...before, branch: null}), false);
assert.equal(preservesConversation(before, {...before, artifactIds: ["art_quote1234"]}), false);
assert.equal(preservesConversation(before, {...before,
artifactIds: [...before.artifactIds].reverse()}), false);
});
test("list and search calls use bounded encoded same-origin queries", async () => {
const responses = [
{body: envelope("hux.projects.page.v1", [project()])},
{body: envelope("hux.conversations.page.v1", [conversation()])},
{body: envelope("hux.conversations.search.v1", [conversation()], {next_cursor: null})},
];
const {client, calls} = harness(responses);
const projects = await client.listProjects("cursor_1", 500);
assert.equal(projects.items[0].id, PROJECT_ID);
assert.equal(projects.nextCursor, "next_page");
assert.equal(projects.total, 1);
const ids = new Set([PROJECT_ID]);
await client.listConversations(PROJECT_ID, ids, null, 12);
await client.searchConversations({query: " cabinets & quotes ", projectId: PROJECT_ID,
tags: ["home", "suppliers"], pinned: false, limit: 20}, ids);
assert.match(calls[0].url, /^\/hux\/v1\/projects\?cursor=cursor_1&limit=100$/);
assert.match(calls[1].url, /\/projects\/prj_alpha1234\/conversations\?limit=12$/);
assert.match(calls[2].url, /q=cabinets\+%26\+quotes/);
assert.match(calls[2].url, /project_id=prj_alpha1234/);
assert.match(calls[2].url, /tag=home&tag=suppliers/);
assert.match(calls[2].url, /pinned=false/);
calls.forEach(({init}) => {
assert.equal(init.credentials, "same-origin");
assert.equal(init.cache, "no-store");
assert.equal(init.headers.Accept, "application/vnd.hermes.hux+json; version=1");
});
});
test("transport rejects invalid capability, identity, pagination, records, and boundaries", async () => {
const disabled = harness([{body: {}}], [FOUNDATION_FLAG]);
await assert.rejects(disabled.client.listProjects(), /not enabled/);
disabled.foundation.apiVersion = "hux.v0";
await assert.rejects(disabled.client.listProjects(), /not enabled/);
assert.throws(() => createOrganizationClient({client: null, fetcher: async () => ({})}), /requires/);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = undefined;
assert.throws(() => createOrganizationClient({client: disabled.foundation}), /requires/);
} finally { globalThis.fetch = originalFetch; }
const failures = [
{body: null},
{body: []},
{body: envelope("wrong", [])},
{body: envelope("hux.projects.page.v1", [], {api_version: "hux.v0"})},
{body: envelope("hux.projects.page.v1", [], {identity: {...RAW_IDENTITY, surface: "worker"}})},
{body: envelope("hux.projects.page.v1", [], {next_cursor: "bad/cursor"})},
{body: envelope("hux.projects.page.v1", [], {total: -1})},
{body: envelope("hux.projects.page.v1", Array(101).fill(project()))},
{body: envelope("hux.projects.page.v1", [project({id: "bad"})])},
{body: envelope("hux.projects.page.v1", [project(), project()])},
];
for (const response of failures) {
const current = harness([response]);
await assert.rejects(current.client.listProjects(), OrganizationContractError);
}
const http = harness([{ok: false, status: 503}]);
await assert.rejects(http.client.listProjects(), /failed \(503\)/);
const conflict = harness([{ok: false, status: 409}]);
await assert.rejects(conflict.client.listProjects(), /changed/);
const badCursor = harness([]);
await assert.rejects(badCursor.client.listProjects("bad/cursor"), /cursor/);
await assert.rejects(badCursor.client.listConversations("prj_other1234",
new Set([PROJECT_ID])), /outside/);
await assert.rejects(badCursor.client.searchConversations({query: "", limit: 1}, new Set()), /1 to 200/);
await assert.rejects(badCursor.client.searchConversations({query: "x".repeat(201)}, new Set()), /1 to 200/);
await assert.rejects(badCursor.client.searchConversations({query: "x", projectId: "prj_other1234"},
new Set([PROJECT_ID])), /outside/);
await assert.rejects(badCursor.client.searchConversations({query: "x", tags: ["Bad"]},
new Set()), /tags/);
});
test("conversation pages cannot cross their requested project", async () => {
const ids = new Set([PROJECT_ID, "prj_other1234"]);
const wrong = harness([{body: envelope("hux.conversations.page.v1",
[conversation({project_id: "prj_other1234"})])}]);
await assert.rejects(wrong.client.listConversations(PROJECT_ID, ids), /project boundary/);
const invalid = harness([{body: envelope("hux.conversations.search.v1", [conversation({id: "bad"})])}]);
await assert.rejects(invalid.client.searchConversations({query: "x"}, ids), /Invalid conversation/);
const oversized = harness([{body: envelope("hux.conversations.search.v1", Array(101).fill(conversation()))}]);
await assert.rejects(oversized.client.searchConversations({query: "x"}, ids), /exceeds/);
const duplicate = harness([{body: envelope("hux.conversations.search.v1", [conversation(), conversation()])}]);
await assert.rejects(duplicate.client.searchConversations({query: "x"}, ids), /Invalid conversation/);
});
test("project mutations send only scoped fields and verify stable identity", async () => {
const before = normalizeProject(project(), IDENTITY.userRef);
const changed = project({name: "New name", tags: ["new"], pinned: false,
updated_at: "2026-08-24T10:00:00Z"});
const {client, calls} = harness([{body: mutation("hux.project.response.v1", changed)}]);
const after = await client.updateProject(before, {name: " New name ", tags: ["new"], pinned: false,
expectedUpdatedAt: before.updatedAt});
assert.equal(after.name, "New name");
const body = JSON.parse(calls[0].init.body);
assert.deepEqual(body, {expected_updated_at: before.updatedAt, name: "New name", tags: ["new"], pinned: false});
assert.equal("tenant_ref" in body, false);
assert.equal(calls[0].init.method, "PATCH");
for (const [patch, message] of [
[{expectedUpdatedAt: "bad"}, /outside/],
[{expectedUpdatedAt: before.updatedAt, name: ""}, /invalid/],
[{expectedUpdatedAt: before.updatedAt, tags: ["Bad"]}, /invalid/],
]) {
await assert.rejects(client.updateProject(before, patch), message);
}
await assert.rejects(client.updateProject({...before, owner: "usr_aaaaaaaaaaaaaaaa"},
{expectedUpdatedAt: before.updatedAt}), /outside/);
const noItem = harness([{body: mutation("hux.project.response.v1", changed, {item: undefined})}]);
await assert.rejects(noItem.client.updateProject(before,
{expectedUpdatedAt: before.updatedAt}), /no item/);
const changedId = harness([{body: mutation("hux.project.response.v1", project({id: "prj_other1234"}))}]);
await assert.rejects(changedId.client.updateProject(before,
{expectedUpdatedAt: before.updatedAt}), /identity changed/);
});
test("conversation mutation proves atomic move invariants", async () => {
const ids = new Set([PROJECT_ID, "prj_other1234"]);
const before = normalizeConversation(conversation(), IDENTITY.userRef, ids);
const moved = conversation({project_id: "prj_other1234", title: "Renamed", tags: ["new"], pinned: true,
updated_at: "2026-08-24T10:00:00Z"});
const {client, calls} = harness([{body: mutation("hux.conversation.response.v1", moved)}]);
const after = await client.updateConversation(before, {projectId: "prj_other1234", title: " Renamed ",
tags: ["new"], pinned: true, expectedUpdatedAt: before.updatedAt}, ids);
assert.equal(after.projectId, "prj_other1234");
assert.deepEqual(JSON.parse(calls[0].init.body), {expected_updated_at: before.updatedAt,
title: "Renamed", project_id: "prj_other1234", tags: ["new"], pinned: true});
for (const [candidate, error] of [
[{...moved, id: "conv_other1234"}, /lineage/],
[{...moved, branch: undefined}, /lineage/],
[{...moved, artifact_ids: ["art_quote1234"]}, /lineage/],
[{...moved, owner: "usr_aaaaaaaaaaaaaaaa"}, /lineage/],
]) {
const current = harness([{body: mutation("hux.conversation.response.v1", candidate)}]);
await assert.rejects(current.client.updateConversation(before,
{expectedUpdatedAt: before.updatedAt}, ids), error);
}
const invalid = harness([]);
await assert.rejects(invalid.client.updateConversation({...before, owner: "usr_aaaaaaaaaaaaaaaa"},
{expectedUpdatedAt: before.updatedAt}, ids), /outside/);
await assert.rejects(invalid.client.updateConversation({...before, projectId: "prj_missing1234"},
{expectedUpdatedAt: before.updatedAt}, ids), /outside/);
await assert.rejects(invalid.client.updateConversation(before,
{expectedUpdatedAt: "bad"}, ids), /outside/);
await assert.rejects(invalid.client.updateConversation(before,
{expectedUpdatedAt: before.updatedAt, projectId: "prj_missing1234"}, ids), /outside/);
await assert.rejects(invalid.client.updateConversation(before,
{expectedUpdatedAt: before.updatedAt, title: ""}, ids), /invalid/);
await assert.rejects(invalid.client.updateConversation(before,
{expectedUpdatedAt: before.updatedAt, tags: ["Bad"]}, ids), /invalid/);
});

View File

@ -0,0 +1,99 @@
"""Executable and static quality gates for the isolated HUX-03 frontend."""
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ORGANIZATION = ROOT / "dockerfiles" / "hermes-webui-hux" / "organization"
def test_hux_organization_node_suite_and_line_branch_coverage():
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-branches=95",
"--test-coverage-functions=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/organization/client.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/organization/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/organization/security.ts",
"testing/tests/test_hermes_hux_ui_organization.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_organization_component_is_flagged_accessible_and_scope_safe():
component = (ORGANIZATION / "OrganizationWorkspace.tsx").read_text(encoding="utf-8")
model = (ORGANIZATION / "model.ts").read_text(encoding="utf-8")
client = (ORGANIZATION / "client.ts").read_text(encoding="utf-8")
contracts = (ORGANIZATION / "contracts.ts").read_text(encoding="utf-8")
styles = (ORGANIZATION / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled) return null" in component
assert "projectsEnabled(props.flags)" in component
assert "FOUNDATION_FLAG" in model and "PROJECTS_FLAG" in model
assert 'role="search"' in component
assert 'role="status"' in component and 'aria-live="polite"' in component
assert 'aria-label="Project folders"' in component
assert 'aria-label="Conversations"' in component
assert 'aria-labelledby="hux-org-lineage-title"' in component
assert 'aria-labelledby="hux-org-attachments-title"' in component
assert "Edit tags" in component and 'role="alert"' in component
assert "encodeURIComponent(conversation.id)" in component
assert "dangerouslySetInnerHTML" not in component
assert all(token not in client for token in ("localStorage", "sessionStorage", "indexedDB", "document.cookie"))
assert 'credentials: "same-origin"' in client and 'cache: "no-store"' in client
assert "expected_updated_at" in client and "preservesConversation" in client
assert "tenant_ref" in contracts and "MUST NOT be accepted" in contracts
assert ".hux-organization" in styles
assert "body" not in styles and ":root" not in styles
assert "prefers-reduced-motion" in styles
def test_hux_organization_sources_respect_size_and_scope_boundaries():
sources = sorted(ORGANIZATION.glob("*"))
assert {path.name for path in sources} == {
"OrganizationWorkspace.tsx",
"client.ts",
"contracts.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
assert "dockerfiles/hermes-webui-hux/organization" not in (
ROOT / "dockerfiles" / "Dockerfile.hermes-agent"
).read_text(encoding="utf-8")
def test_hux_organization_declares_exact_backend_contracts():
contracts = (ORGANIZATION / "contracts.ts").read_text(encoding="utf-8")
expected = (
"GET /hux/v1/projects?cursor=&limit=",
"GET /hux/v1/projects/{prj_id}/conversations?cursor=&limit=",
"GET /hux/v1/conversations/search?q=&project_id=&tag=&pinned=&cursor=&limit=",
"PATCH /hux/v1/projects/{prj_id}",
"PATCH /hux/v1/conversations/{conv_id}",
'api_version="hux.v1"',
"next_cursor",
"expected_updated_at",
"branch",
"artifact_ids",
"409",
)
for value in expected:
assert value in contracts

View File

@ -0,0 +1,312 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createPrivacyClient,
PrivacyContractError,
} from "../../dockerfiles/hermes-webui-hux/privacy/client.ts";
import {
FOUNDATION_FLAG,
GENERIC_NOTICE,
PRIVACY_FLAG,
normalizeNotice,
normalizeSnapshot,
privacyControls,
privacyEnabled,
withoutNotice,
} from "../../dockerfiles/hermes-webui-hux/privacy/model.ts";
import {
asRecord,
exactKeys,
isId,
isSafeText,
isUtc,
normalizeIdentity,
normalizeScope,
privacyQuery,
sameIdentity,
} from "../../dockerfiles/hermes-webui-hux/privacy/security.ts";
const IDENTITY = Object.freeze({
tenantRef: "tnt_0123456789abcdef",
userRef: "usr_0123456789abcdef",
surface: "chat",
});
const RAW_IDENTITY = Object.freeze({
tenant_ref: IDENTITY.tenantRef,
user_ref: IDENTITY.userRef,
surface: IDENTITY.surface,
});
const SCOPE = Object.freeze({
sessionId: "ses_alpha1234",
conversationId: "conv_alpha1234",
});
const SHOWN_AT = "2026-08-24T10:00:00Z";
function notice(extra = {}) {
return {
schema: "hux.privacy_notice.v1",
topic: "health",
conversation_id: SCOPE.conversationId,
text: "This looks like a health topic. It stays in this conversation.",
controls: [
"forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss",
],
shown_at: SHOWN_AT,
...extra,
};
}
function retention(extra = {}) {
return {
scope: "conversation",
memory_write: "ask",
topic_context_decay_days: 30,
audit_interval_days: 1,
next_audit_at: "2026-08-25T03:00:00Z",
cross_surface_sharing: "never",
...extra,
};
}
function snapshot(extra = {}) {
return {
schema: "hux.privacy_snapshot.v1",
api_version: "hux.v1",
identity: RAW_IDENTITY,
session_id: SCOPE.sessionId,
conversation_id: SCOPE.conversationId,
revision: 1,
no_store: false,
memory_enabled: true,
retention: retention(),
notice: notice(),
...extra,
};
}
function noStore(extra = {}) {
return snapshot({
revision: 2,
no_store: true,
memory_enabled: false,
retention: retention({scope: "session", memory_write: "deny",
topic_context_decay_days: null}),
notice: null,
...extra,
});
}
function memoryOff(extra = {}) {
return snapshot({
revision: 2,
memory_enabled: false,
retention: retention({memory_write: "deny", topic_context_decay_days: null}),
notice: null,
...extra,
});
}
function harness(responses, flags = [FOUNDATION_FLAG, PRIVACY_FLAG]) {
const calls = [];
const enabled = new Set(flags);
const foundation = {
apiVersion: "hux.v1",
identity: IDENTITY,
enabled: (flag) => enabled.has(flag),
endpoint: (path) => `/hux/v1${path}`,
};
const fetcher = async (url, init) => {
calls.push({url, init});
const response = responses.shift();
if (!response) throw new Error("unexpected request");
return {ok: response.ok ?? true, status: response.status ?? 200,
json: async () => response.body};
};
return {calls, foundation,
client: createPrivacyClient({client: foundation, fetcher})};
}
test("HUX-10 is default-off and requires the foundation", () => {
assert.equal(privacyEnabled(), false);
assert.equal(privacyEnabled([]), false);
assert.equal(privacyEnabled([FOUNDATION_FLAG]), false);
assert.equal(privacyEnabled([PRIVACY_FLAG]), false);
assert.equal(privacyEnabled([FOUNDATION_FLAG, PRIVACY_FLAG]), true);
});
test("security primitives bind opaque scope and same-origin query", () => {
assert.deepEqual(asRecord({a: 1}), {a: 1});
assert.equal(asRecord(null), null);
assert.equal(asRecord([]), null);
assert.equal(exactKeys({a: 1, b: 2}, ["a"], ["b"]), true);
assert.equal(exactKeys({a: 1, x: 2}, ["a"]), false);
assert.equal(isId("conv_alpha1234", "conv"), true);
assert.equal(isId("bad", "conv"), false);
assert.equal(isId("ses_alpha1234", "conv"), false);
assert.equal(isUtc("2026-08-24T10:00:00Z"), true);
assert.equal(isUtc("2026-99-99T10:00:00Z"), false);
assert.equal(isSafeText("safe", 1, 20), true);
assert.equal(isSafeText("bad\u0000", 1, 20), false);
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
for (const raw of [null, [], {}, {...RAW_IDENTITY, extra: true},
{...RAW_IDENTITY, tenant_ref: "bad"}, {...RAW_IDENTITY, user_ref: "bad"},
{...RAW_IDENTITY, surface: "browser"}]) assert.equal(normalizeIdentity(raw), null);
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
assert.equal(sameIdentity(IDENTITY, {...IDENTITY, surface: "worker"}), false);
assert.deepEqual(normalizeScope(SCOPE), SCOPE);
for (const raw of [null, {...SCOPE, extra: true}, {...SCOPE, sessionId: "bad"},
{...SCOPE, conversationId: "bad"}]) assert.equal(normalizeScope(raw), null);
assert.equal(privacyQuery(SCOPE),
"/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234");
assert.throws(() => privacyQuery({...SCOPE, sessionId: "bad"}), TypeError);
});
test("notice validation drops category and source text from view state", () => {
const item = normalizeNotice(notice(), SCOPE.conversationId, "ask", 30);
assert.equal(item.text, GENERIC_NOTICE);
assert.equal("topic" in item, false);
assert.deepEqual(item.controls, notice().controls);
const restricted = normalizeNotice(notice({topic: "credentials"}),
SCOPE.conversationId, "deny", 1);
assert.equal(restricted.text, GENERIC_NOTICE);
const invalid = [null, {...notice(), extra: true}, {...notice(), schema: "old"},
{...notice(), topic: "politics"}, {...notice(), conversation_id: "conv_other1234"},
{...notice(), text: ""}, {...notice(), text: "bad\u0000"},
{...notice(), shown_at: "today"}, {...notice(), controls: null},
{...notice(), controls: []}, {...notice(), controls: [...notice().controls, "dismiss"]},
{...notice(), controls: ["unknown"]}];
invalid.forEach((raw) => assert.equal(
normalizeNotice(raw, SCOPE.conversationId, "ask", 30), null));
assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "deny", 30), null);
assert.equal(normalizeNotice(notice(), SCOPE.conversationId, "ask", 1), null);
});
test("snapshot exposes generic retention while enforcing policy semantics", () => {
const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE);
assert.equal(item.notice.text, GENERIC_NOTICE);
assert.equal(item.retention.topicContextDecayDays, 30);
const privateItem = normalizeSnapshot(noStore(), IDENTITY, SCOPE);
assert.equal(privateItem.noStore, true);
assert.equal(privateItem.retention.scope, "session");
assert.equal(normalizeSnapshot(memoryOff(), IDENTITY, SCOPE).memoryEnabled, false);
const restricted = normalizeSnapshot(snapshot({memory_enabled: false,
retention: retention({memory_write: "deny", topic_context_decay_days: 1}),
notice: notice({topic: "credentials"})}), IDENTITY, SCOPE);
assert.equal(restricted.notice.text, GENERIC_NOTICE);
const changedTopic = normalizeSnapshot(snapshot({notice: null}), IDENTITY, SCOPE);
assert.equal(changedTopic.notice, null);
});
test("snapshot rejects malformed scope, retention, no-store and notice state", () => {
const invalid = [null, {...snapshot(), extra: true}, {...snapshot(), schema: "old"},
{...snapshot(), api_version: "hux.v0"},
{...snapshot(), identity: {...RAW_IDENTITY, surface: "worker"}},
{...snapshot(), session_id: "ses_other1234"},
{...snapshot(), conversation_id: "conv_other1234"},
{...snapshot(), revision: 0}, {...snapshot(), no_store: "no"},
{...snapshot(), memory_enabled: "yes"}, {...snapshot(), retention: null},
{...snapshot(), retention: {...retention(), extra: true}},
{...snapshot(), retention: retention({scope: "forever"})},
{...snapshot(), retention: retention({memory_write: "automatic"})},
{...snapshot(), retention: retention({topic_context_decay_days: 0})},
{...snapshot(), retention: retention({audit_interval_days: 31})},
{...snapshot(), retention: retention({next_audit_at: "tomorrow"})},
{...snapshot(), retention: retention({cross_surface_sharing: "everyone"})},
{...snapshot(), no_store: true},
{...noStore(), retention: retention({scope: "session", memory_write: "deny",
topic_context_decay_days: 1})},
{...noStore(), memory_enabled: true},
{...snapshot(), memory_enabled: false},
{...snapshot(), notice: notice({topic: "credentials"})},
];
invalid.forEach((raw) => assert.equal(normalizeSnapshot(raw, IDENTITY, SCOPE), null));
});
test("view helpers suppress repeats and expose only effective controls", () => {
const item = normalizeSnapshot(snapshot(), IDENTITY, SCOPE);
assert.equal(withoutNotice(item).notice, null);
const changed = withoutNotice({...item, notice: null});
assert.equal(changed.notice, null);
assert.deepEqual(privacyControls(item), [
"enable_no_store", "disable_memory_here", "forget_this_conversation",
"switch_to_private", "dismiss",
]);
assert.deepEqual(privacyControls(normalizeSnapshot(noStore(), IDENTITY, SCOPE)),
["disable_no_store"]);
});
test("client loads scoped no-cache state and does not reiterate notices", async () => {
const current = harness([{body: snapshot()}, {body: snapshot()},
{body: snapshot({revision: 2, notice: null})},
{body: snapshot({revision: 3, notice: notice({topic: "finance",
shown_at: "2026-08-24T11:00:00Z"})})}]);
assert.equal(current.client.enabled(), true);
const first = await current.client.load(SCOPE);
assert.ok(first.notice);
assert.equal((await current.client.load(SCOPE)).notice, null);
assert.equal((await current.client.load(SCOPE)).notice, null);
assert.ok((await current.client.load(SCOPE)).notice);
assert.equal(current.calls[0].url,
"/hux/v1/privacy/snapshot?session_id=ses_alpha1234&conversation_id=conv_alpha1234");
assert.equal(current.calls[0].init.method, "GET");
assert.equal(current.calls[0].init.cache, "no-store");
assert.equal(current.calls[0].init.credentials, "same-origin");
});
test("client applies each explicit control with optimistic scope binding", async () => {
const cases = [
["enable_no_store", noStore()],
["switch_to_private", noStore()],
["forget_this_conversation", noStore()],
["disable_memory_here", memoryOff()],
["dismiss", snapshot({revision: 2, notice: null})],
];
for (const [action, result] of cases) {
const current = harness([{body: snapshot()}, {body: result}]);
const before = await current.client.load(SCOPE);
const after = await current.client.control(before, action);
assert.equal(after.revision, 2);
assert.equal(current.calls[1].url, "/hux/v1/privacy/controls");
assert.deepEqual(JSON.parse(current.calls[1].init.body), {
schema: "hux.privacy_control_request.v1", session_id: SCOPE.sessionId,
conversation_id: SCOPE.conversationId, expected_revision: 1, action,
});
await assert.rejects(current.client.control(before, action), /not bound/);
}
const current = harness([{body: noStore({revision: 1})},
{body: snapshot({revision: 2, notice: null})}]);
const before = await current.client.load(SCOPE);
assert.equal((await current.client.control(before, "disable_no_store")).noStore, false);
});
test("client fails closed for disabled, invalid, stale and cross-scope data", async () => {
const disabled = harness([], [FOUNDATION_FLAG]);
assert.equal(disabled.client.enabled(), false);
await assert.rejects(disabled.client.load(SCOPE), /not enabled/);
disabled.foundation.apiVersion = "hux.v0";
await assert.rejects(disabled.client.load(SCOPE), /not enabled/);
const invalidScope = harness([]);
await assert.rejects(invalidScope.client.load({...SCOPE, sessionId: "bad"}), /scope/);
await assert.rejects(invalidScope.client.control({}, "enable_no_store"), /not bound/);
for (const response of [{ok: false, status: 409}, {ok: false, status: 503}]) {
const current = harness([response]);
await assert.rejects(current.client.load(SCOPE), PrivacyContractError);
}
const malformed = harness([{body: {}}]);
await assert.rejects(malformed.client.load(SCOPE), /crossed/);
const wrongEffect = harness([{body: snapshot()}, {body: snapshot({revision: 2})}]);
const item = await wrongEffect.client.load(SCOPE);
await assert.rejects(wrongEffect.client.control(item, "enable_no_store"), /verified/);
const stale = harness([{body: snapshot()}, {body: noStore({revision: 1})}]);
await assert.rejects(stale.client.control(await stale.client.load(SCOPE),
"enable_no_store"), /verified/);
assert.throws(() => createPrivacyClient({client: null, fetcher: async () => ({})}), /requires/);
assert.throws(() => createPrivacyClient({client: {...invalidScope.foundation,
identity: {...IDENTITY, userRef: "bad"}}, fetcher: async () => ({})}), /identity/);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = undefined;
assert.throws(() => createPrivacyClient({client: invalidScope.foundation}), /requires/);
} finally { globalThis.fetch = originalFetch; }
});

View File

@ -0,0 +1,119 @@
"""Executable and static quality gates for the isolated HUX-10 frontend."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PRIVACY = ROOT / "dockerfiles" / "hermes-webui-hux" / "privacy"
def test_hux_privacy_node_suite_and_per_source_coverage():
"""Privacy adapters and transport remain above the repository gate."""
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-branches=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/privacy/client.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/privacy/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/privacy/security.ts",
"testing/tests/test_hermes_hux_ui_privacy.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
for row in ("client.ts", "model.ts", "security.ts"):
report = next(line for line in result.stdout.splitlines() if row in line)
percentages = [float(part) for part in report.split("|")[1:4]]
assert min(percentages) >= 95.0, report
def test_hux_privacy_matches_contract_and_stays_inert():
"""HUX-10 uses shared topic/notice rules without live integration."""
schema = json.loads(
(ROOT / "services/hermes/contracts/hux/privacy.schema.json").read_text(
encoding="utf-8"
)
)
model = (PRIVACY / "model.ts").read_text(encoding="utf-8")
security = (PRIVACY / "security.ts").read_text(encoding="utf-8")
for topic in schema["$defs"]["topic"]["enum"]:
assert f"{topic}:" in model
for control in schema["$defs"]["notice"]["properties"]["controls"][
"items"
]["enum"]:
assert f'"{control}"' in model
assert "hux.privacy_notice.v1" in model and "hux.v1" in model
assert "conversationId" in security and "sessionId" in security
assert "dockerfiles/hermes-webui-hux/privacy" not in (
ROOT / "dockerfiles/Dockerfile.hermes-webui"
).read_text(encoding="utf-8")
def test_hux_privacy_ui_is_accessible_minimized_and_explicit():
"""The UI explains retention without repeating sensitive categories."""
component = (PRIVACY / "PrivacyCenter.tsx").read_text(encoding="utf-8")
model = (PRIVACY / "model.ts").read_text(encoding="utf-8")
client = (PRIVACY / "client.ts").read_text(encoding="utf-8")
contracts = (PRIVACY / "contracts.ts").read_text(encoding="utf-8")
styles = (PRIVACY / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled || !snapshot) return null" in component
assert "What Hermes keeps" in component and "Effective retention" in component
assert "No-store active" in component and "Use no-store here" in component
assert "Sensitive-topic protection" in component
assert "Previous sensitive details are not repeated" in component
assert "Retention audit" in component and "auditIntervalDays" in component
assert "Confirm forget" in component and "This cannot be undone" in component
assert 'role="group"' in component and 'role="status"' in component
assert 'aria-live="polite"' in component and 'role="alert"' in component
assert "snapshot.notice.topic" not in component
assert "dangerouslySetInnerHTML" not in component
assert "GENERIC_NOTICE" in model and "Never carry the category" in model
assert "seenNotices" in client and "withoutNotice" in client
assert all(
token not in client
for token in ("localStorage", "sessionStorage", "indexedDB", "document.cookie")
)
assert 'credentials: "same-origin"' in client and 'cache: "no-store"' in client
assert "MUST omit inactive or previous topic" in contracts
assert "Topic changes clear the prior notice" in contracts
assert ".hux-privacy" in styles
assert "body" not in styles and ":root" not in styles
assert "focus-visible" in styles and "prefers-reduced-motion" in styles
def test_hux_privacy_sources_are_bounded_and_endpoint_only():
"""The slice defines API needs but changes no runtime or release wiring."""
sources = sorted(PRIVACY.glob("*"))
assert {path.name for path in sources} == {
"PrivacyCenter.tsx",
"client.ts",
"contracts.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) < 500
contracts = (PRIVACY / "contracts.ts").read_text(encoding="utf-8")
assert "GET /hux/v1/privacy/snapshot?session_id=&conversation_id=" in contracts
assert "POST /hux/v1/privacy/controls" in contracts
assert "expected_revision" in contracts and "A 409 rejects stale state" in contracts

View File

@ -0,0 +1,242 @@
import assert from "node:assert/strict";
import test from "node:test";
import {createResearchEndpointContract} from "../../dockerfiles/hermes-webui-hux/research/endpoints.ts";
import {
FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, RESEARCH_FLAG, normalizeResearchPage, researchEnabled,
} from "../../dockerfiles/hermes-webui-hux/research/model.ts";
import {
asRecord, boundedText, isHash, isId, isUtc, normalizeIdentity, normalizeScope,
safeMessageId, safePathId, safeSourceUri, sameIdentity, sameScope,
} from "../../dockerfiles/hermes-webui-hux/research/security.ts";
const IDENTITY = {tenantRef: "tnt_0123456789abcdef", userRef: "usr_0123456789abcdef", surface: "chat"};
const RAW_IDENTITY = {tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: "chat"};
const SCOPE = {projectId: "prj_project123", conversationId: "conv_converse1"};
const RAW_SCOPE = {project_id: SCOPE.projectId, conversation_id: SCOPE.conversationId};
const HASH = `sha256:${"a".repeat(64)}`;
const FLAGS = [FOUNDATION_FLAG, FRIENDLY_MODES_FLAG, RESEARCH_FLAG];
function source(extra = {}) {
return {schema: "hux.source.v1", id: "src_primary12", kind: "web",
uri: "https://example.com/report", title: "Primary report", publisher: "Example Institute",
published_at: "2026-08-20T10:00:00Z", retrieved_at: "2026-08-24T10:00:00Z",
classification: "primary", content_hash: HASH, provenance: {surface: "chat",
actor: {type: "assistant", id: "hermes"}, recorded_at: "2026-08-24T10:00:01Z",
conversation_id: SCOPE.conversationId, run_id: "run_research"}, ...extra};
}
function passage(extra = {}) {
return {schema: "hux.passage.v1", id: "psg_passage12", source_id: "src_primary12",
text: "The exact supporting passage.\nSecond line.", hash: HASH,
locator: {page: 2, selector: "#finding", line_start: 10, line_end: 12,
char_start: 50, char_end: 91}, ...extra};
}
function citation(extra = {}) {
return {schema: "hux.citation.v1", id: "cit_citation1", message_id: "msg-44",
claim: "The report supports this claim.", passage_ids: ["psg_passage12"],
support: "supports", note: "Check the methodology.", ...extra};
}
function notebook(extra = {}) {
return {schema: "hux.research_notebook.v1", id: "nb_notebook12",
conversation_id: SCOPE.conversationId, question: "What does the evidence show?", status: "open",
source_ids: ["src_primary12"], passage_ids: ["psg_passage12"], citation_ids: ["cit_citation1"],
assumptions: ["The publication date is accurate."], unresolved_questions: ["Was it peer reviewed?"],
updated_at: "2026-08-24T10:00:02Z", ...extra};
}
function page(extra = {}) {
return {schema: "hux.research_page.v1", api_version: "hux.v1", identity: RAW_IDENTITY,
binding: RAW_SCOPE, sources: [source()], passages: [passage()], citations: [citation()],
notebooks: [notebook()], ...extra};
}
test("research stays default-off until its declared capabilities are active", () => {
assert.equal(researchEnabled(), false);
assert.equal(researchEnabled([]), false);
for (const flag of FLAGS) assert.equal(researchEnabled(FLAGS.filter((item) => item !== flag)), false);
assert.equal(researchEnabled(FLAGS), true);
});
test("security helpers enforce exact identity, scope, text, IDs, timestamps and hashes", () => {
assert.deepEqual(asRecord({a: 1}), {a: 1});
assert.equal(asRecord(null), null);
assert.equal(asRecord([]), null);
assert.equal(isId("src_primary12", "src"), true);
assert.equal(isId("psg_passage12", "src"), false);
assert.equal(isId(4), false);
assert.equal(isUtc("2026-08-24T10:00:00.123Z"), true);
assert.equal(isUtc("today"), false);
assert.equal(isUtc(4), false);
assert.equal(isHash(HASH), true);
assert.equal(isHash(`sha256:${"z".repeat(64)}`), false);
assert.equal(isHash(4), false);
assert.equal(boundedText("hello", 1, 5), true);
assert.equal(boundedText("", 1, 5), false);
assert.equal(boundedText("bad\u0000", 1, 10), false);
assert.equal(boundedText(4, 1, 5), false);
assert.deepEqual(normalizeIdentity(RAW_IDENTITY), IDENTITY);
for (const bad of [null, [], {...RAW_IDENTITY, tenant_ref: "bad"},
{...RAW_IDENTITY, user_ref: "bad"}, {...RAW_IDENTITY, surface: "browser"}]) {
assert.equal(normalizeIdentity(bad), null);
}
assert.deepEqual(normalizeScope(RAW_SCOPE), SCOPE);
assert.equal(normalizeScope(null), null);
assert.equal(normalizeScope({...RAW_SCOPE, project_id: "bad"}), null);
assert.equal(normalizeScope({...RAW_SCOPE, conversation_id: "bad"}), null);
assert.equal(sameIdentity(IDENTITY, IDENTITY), true);
assert.equal(sameIdentity(null, IDENTITY), false);
assert.equal(sameIdentity({...IDENTITY, surface: "voice"}, IDENTITY), false);
assert.equal(sameScope(SCOPE, SCOPE), true);
assert.equal(sameScope(null, SCOPE), false);
assert.equal(sameScope({...SCOPE, conversationId: "conv_other1234"}, SCOPE), false);
});
test("source URLs allow HTTPS and HUX paths but reject active or ambiguous schemes", () => {
assert.equal(safeSourceUri(undefined), null);
assert.equal(safeSourceUri(null), null);
assert.equal(safeSourceUri(""), null);
assert.equal(safeSourceUri("https://example.com/a?q=1"), "https://example.com/a?q=1");
assert.equal(safeSourceUri("/hux/v1/artifacts/art_safe1234"), "/hux/v1/artifacts/art_safe1234");
for (const unsafe of ["http://example.com", "javascript:alert(1)", "data:text/html,x",
"//example.com/x", "/hux/v1/../secret", "/hux/v1/x?q=1", "https://u:p@example.com", "not a url",
`https://example.com/${"x".repeat(2000)}`, "https://example.com/\u0000"] ) {
assert.equal(safeSourceUri(unsafe), null);
}
assert.equal(safeMessageId("msg-44"), "msg-44");
assert.throws(() => safeMessageId("bad/path"), TypeError);
assert.throws(() => safeMessageId(""), TypeError);
assert.equal(safePathId("nb_notebook12", "nb"), "nb_notebook12");
assert.throws(() => safePathId("bad", "nb"), TypeError);
});
test("valid research keeps provenance, bounded passages, support, and notebook notes", () => {
const result = normalizeResearchPage(page(), IDENTITY, SCOPE);
assert.equal(result.rejected, 0);
assert.equal(result.sources[0].classification, "primary");
assert.equal(result.sources[0].provenance.actorType, "assistant");
assert.equal(result.sources[0].provenance.runId, "run_research");
assert.equal(result.passages[0].text, "The exact supporting passage.\nSecond line.");
assert.equal(result.passages[0].locator, "page 2 · #finding · lines 10–12 · characters 50–91");
assert.equal(result.citations[0].support, "supports");
assert.deepEqual(result.notebooks[0].notes, ["Check the methodology."]);
assert.deepEqual(result.notebooks[0].unresolvedQuestions, ["Was it peer reviewed?"]);
});
test("envelope rejects cross-tenant, user, project and conversation data", () => {
const bad = [null, {}, page({schema: "old"}), page({api_version: "hux.v0"}),
page({identity: {...RAW_IDENTITY, tenant_ref: "tnt_ffffffffffffffff"}}),
page({identity: {...RAW_IDENTITY, user_ref: "usr_ffffffffffffffff"}}),
page({identity: {...RAW_IDENTITY, surface: "voice"}}),
page({binding: {...RAW_SCOPE, project_id: "prj_other1234"}}),
page({binding: {...RAW_SCOPE, conversation_id: "conv_other1234"}}),
page({sources: null}), page({sources: Array(501).fill(source())}),
page({passages: null}), page({passages: Array(501).fill(passage())}),
page({citations: null}), page({citations: Array(501).fill(citation())}),
page({notebooks: null}), page({notebooks: Array(101).fill(notebook())})];
for (const raw of bad) assert.equal(normalizeResearchPage(raw, IDENTITY, SCOPE), null);
});
test("invalid source metadata, provenance and URL are withheld with dependants", () => {
const badSources = [null, source({schema: "old"}), source({id: "bad"}), source({kind: "video"}),
source({title: ""}), source({publisher: "x".repeat(201)}), source({retrieved_at: "today"}),
source({classification: "official"}), source({published_at: "today"}), source({content_hash: "bad"}),
source({published_at: "2026-08-25T10:00:00Z"}),
source({uri: "javascript:alert(1)"}), source({provenance: null}),
source({provenance: {...source().provenance, surface: "browser"}}),
source({provenance: {...source().provenance, actor: null}}),
source({provenance: {...source().provenance, actor: {type: "intruder"}}}),
source({provenance: {...source().provenance, actor: {type: "assistant", id: ""}}}),
source({provenance: {...source().provenance, recorded_at: "today"}}),
source({provenance: {...source().provenance, conversation_id: "conv_other1234"}}),
source({provenance: {...source().provenance, run_id: ""}})];
for (const raw of badSources) {
const result = normalizeResearchPage(page({sources: [raw]}), IDENTITY, SCOPE);
assert.equal(result.sources.length, 0);
assert.equal(result.passages.length, 0);
assert.equal(result.citations.length, 0);
assert.equal(result.notebooks.length, 0);
}
const withoutOptional = normalizeResearchPage(page({sources: [source({uri: undefined,
publisher: undefined, published_at: undefined, content_hash: undefined, provenance: undefined})]}), IDENTITY, SCOPE);
assert.equal(withoutOptional.sources[0].uri, null);
assert.equal(withoutOptional.sources[0].provenance, null);
});
test("passage locators and citations are schema-bounded and referentially complete", () => {
const invalidPassages = [null, passage({schema: "old"}), passage({id: "bad"}),
passage({source_id: "src_missing12"}), passage({text: ""}), passage({text: "x".repeat(4001)}),
passage({hash: "bad"}), passage({locator: null}), passage({locator: {unknown: 1}}),
passage({locator: {page: 0}}), passage({locator: {selector: "x".repeat(501)}}),
passage({locator: {line_start: 0}}), passage({locator: {line_end: 0}}),
passage({locator: {char_start: -1}}), passage({locator: {char_end: -1}}),
passage({locator: {line_start: 10, line_end: 9}}), passage({locator: {char_start: 10, char_end: 9}})];
for (const raw of invalidPassages) {
assert.equal(normalizeResearchPage(page({passages: [raw]}), IDENTITY, SCOPE).passages.length, 0);
}
assert.equal(normalizeResearchPage(page({passages: [passage({locator: undefined})]}), IDENTITY, SCOPE)
.passages[0].locator, "");
assert.equal(normalizeResearchPage(page({passages: [passage({locator: {line_start: 4}})]}), IDENTITY, SCOPE)
.passages[0].locator, "lines 4");
const invalidCitations = [null, citation({schema: "old"}), citation({id: "bad"}),
citation({message_id: ""}), citation({claim: ""}), citation({passage_ids: null}),
citation({passage_ids: []}), citation({passage_ids: Array(33).fill("psg_passage12")}),
citation({passage_ids: ["psg_passage12", "psg_passage12"]}),
citation({passage_ids: ["psg_missing12"]}), citation({support: "maybe"}),
citation({note: "x".repeat(501)})];
for (const raw of invalidCitations) {
assert.equal(normalizeResearchPage(page({citations: [raw]}), IDENTITY, SCOPE).citations.length, 0);
}
const allSupport = ["supports", "partially_supports", "contradicts", "unverified"];
for (const support of allSupport) assert.equal(normalizeResearchPage(page({citations: [citation({support})]}),
IDENTITY, SCOPE).citations[0].support, support);
});
test("notebooks validate references, lists, status, conversation and duplicates", () => {
const invalid = [null, notebook({schema: "old"}), notebook({id: "bad"}),
notebook({conversation_id: "conv_other1234"}), notebook({question: ""}), notebook({status: "paused"}),
notebook({updated_at: "today"}), notebook({source_ids: null}), notebook({source_ids: ["src_missing12"]}),
notebook({source_ids: ["src_primary12", "src_primary12"]}), notebook({passage_ids: null}),
notebook({passage_ids: ["psg_missing12"]}), notebook({citation_ids: null}),
notebook({citation_ids: ["cit_missing12"]}), notebook({assumptions: null}),
notebook({assumptions: Array(65).fill("x")}), notebook({assumptions: [""]}),
notebook({unresolved_questions: null}), notebook({unresolved_questions: Array(65).fill("x")}),
notebook({unresolved_questions: [""]})];
for (const raw of invalid) {
assert.equal(normalizeResearchPage(page({notebooks: [raw]}), IDENTITY, SCOPE).notebooks.length, 0);
}
for (const status of ["open", "answered", "abandoned"]) {
assert.equal(normalizeResearchPage(page({notebooks: [notebook({status})]}), IDENTITY, SCOPE)
.notebooks[0].status, status);
}
const duplicate = normalizeResearchPage(page({sources: [source(), source()],
passages: [passage(), passage()], citations: [citation(), citation()], notebooks: [notebook(), notebook()]}),
IDENTITY, SCOPE);
assert.deepEqual([duplicate.sources.length, duplicate.passages.length, duplicate.citations.length,
duplicate.notebooks.length], [1, 1, 1, 1]);
assert.equal(duplicate.rejected, 4);
});
test("endpoint contract is scoped, inert, optimistic, and same-origin delegated", () => {
const client = {apiVersion: "hux.v1", identity: IDENTITY,
endpoint(path) { assert.match(path, /^\/projects\/prj_[^/]+\/conversations\/conv_[^/]+\/research/);
return `/hux/v1${path}`; }};
assert.equal(createResearchEndpointContract(client, SCOPE), null);
assert.equal(createResearchEndpointContract(client, SCOPE, FLAGS.slice(1)), null);
const contract = createResearchEndpointContract(client, SCOPE, FLAGS);
assert.equal(contract.page.path, "/hux/v1/projects/prj_project123/conversations/conv_converse1/research");
assert.match(contract.citationsForMessage("msg-44").path, /messages\/msg-44\/citations$/);
assert.match(contract.notebook("nb_notebook12").path, /notebooks\/nb_notebook12$/);
assert.deepEqual(contract.updateNotebook("nb_notebook12").requiredBody,
["expected_updated_at", "status", "assumptions", "unresolved_questions"]);
assert.deepEqual(contract.attachCitation("msg-44").requiredBody,
["claim", "passage_ids", "support", "note"]);
for (const call of [() => contract.citationsForMessage("bad/path"), () => contract.notebook("bad"),
() => contract.updateNotebook("bad")]) assert.throws(call, TypeError);
assert.throws(() => createResearchEndpointContract({...client, apiVersion: "hux.v2"}, SCOPE, FLAGS), TypeError);
assert.throws(() => createResearchEndpointContract({...client, identity: {...IDENTITY, tenantRef: "bad"}}, SCOPE, FLAGS), TypeError);
assert.throws(() => createResearchEndpointContract(client, {...SCOPE, projectId: "bad"}, FLAGS), TypeError);
});

View File

@ -0,0 +1,103 @@
"""Executable and static quality gates for isolated HUX-08 research UX."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
RESEARCH = ROOT / "dockerfiles" / "hermes-webui-hux" / "research"
def test_hux_research_node_suite_and_coverage():
result = subprocess.run(
[
"node",
"--test",
"--experimental-strip-types",
"--experimental-test-coverage",
"--test-coverage-lines=95",
"--test-coverage-functions=95",
"--test-coverage-branches=95",
"--test-coverage-include=dockerfiles/hermes-webui-hux/research/model.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/research/security.ts",
"--test-coverage-include=dockerfiles/hermes-webui-hux/research/endpoints.ts",
"testing/tests/test_hermes_hux_ui_research.mjs",
],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_hux_research_matches_shared_contract_and_stays_default_off():
schema = json.loads(
(ROOT / "services/hermes/contracts/hux/citation.schema.json").read_text(
encoding="utf-8"
)
)
model = (RESEARCH / "model.ts").read_text(encoding="utf-8")
for definition in ("source", "passage", "citation", "notebook"):
assert schema["$defs"][definition]["properties"]["schema"]["const"] in model
for value in schema["$defs"]["source"]["properties"]["kind"]["enum"]:
assert f'"{value}"' in model
for value in schema["$defs"]["source"]["properties"]["classification"][
"enum"
]:
assert f'"{value}"' in model
for value in schema["$defs"]["citation"]["properties"]["support"]["enum"]:
assert f'"{value}"' in model
assert "researchEnabled(props.flags)" in (
RESEARCH / "ResearchWorkspace.tsx"
).read_text(encoding="utf-8")
assert "dockerfiles/hermes-webui-hux/research" not in (
ROOT / "dockerfiles/Dockerfile.hermes-webui"
).read_text(encoding="utf-8")
def test_hux_research_ui_is_accessible_and_evidence_is_not_executable():
component = (RESEARCH / "ResearchWorkspace.tsx").read_text(encoding="utf-8")
endpoints = (RESEARCH / "endpoints.ts").read_text(encoding="utf-8")
security = (RESEARCH / "security.ts").read_text(encoding="utf-8")
styles = (RESEARCH / "styles.css").read_text(encoding="utf-8")
assert "if (!enabled || !page) return null" in component
assert 'aria-label="Research and citations"' in component
assert 'aria-expanded={expanded}' in component
assert 'aria-controls={panelId}' in component
assert 'aria-live="polite"' in component
assert "primary source" not in component # classification is data-driven
assert "Research notes" in component
assert "Assumptions" in component
assert "Unresolved questions" in component
assert 'rel="noopener noreferrer"' in component
assert "dangerouslySetInnerHTML" not in component
assert "<iframe" not in component and "<object" not in component
assert "javascript:" not in component and "data:" not in component
assert 'parsed.protocol === "https:"' in security
assert "fetch(" not in endpoints and "axios" not in endpoints
assert ".hux-research" in styles
assert "body" not in styles and ":root" not in styles
assert "prefers-reduced-motion" in styles
def test_hux_research_sources_are_bounded_and_endpoint_only():
sources = sorted(RESEARCH.glob("*"))
assert {path.name for path in sources} == {
"ResearchWorkspace.tsx",
"endpoints.ts",
"index.ts",
"model.ts",
"security.ts",
"styles.css",
"types.ts",
}
for path in sources:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
endpoints = (RESEARCH / "endpoints.ts").read_text(encoding="utf-8")
assert "expected_updated_at" in endpoints
assert '"PATCH"' not in endpoints and '"DELETE"' not in endpoints