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
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
/** 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;
|
|
}
|