diff --git a/apps/demo/src/generate-main.ts b/apps/demo/src/generate-main.ts index 79e0af6..bc46ac9 100644 --- a/apps/demo/src/generate-main.ts +++ b/apps/demo/src/generate-main.ts @@ -28,6 +28,7 @@ import { type ScriptPolicy, type SummonLayout, type SurfaceCeiling, + type SurfaceContractView, type SurfacePlan, type ValidationCapability, type ValidationComponent, @@ -379,6 +380,7 @@ let currentShape: string | null = null; let currentValidationSummary: string | null = null; let currentRepairSummary: string | null = null; let currentStreamHealth: string | null = null; +let currentSurfaceContractView: SurfaceContractView | null = null; const acc = new SectionAccumulator(); let handle: SandboxHandle | null = null; let policy: PolicyEngine | null = null; @@ -785,6 +787,7 @@ function applyScenario(scenario: ShowcaseScenario) { currentValidationSummary = null; currentRepairSummary = null; currentStreamHealth = null; + currentSurfaceContractView = null; respawn(currentDirectionId, currentMode); showWelcome(); updateEditControls(); @@ -806,14 +809,31 @@ function planText(plan: { purpose: string; runtime: string; data: string; author ].join(' · '); } +function parseSurfaceContractView(value: unknown): SurfaceContractView | null { + if (!value || typeof value !== 'object') return null; + const contract = value as Partial; + if (!contract.surface || typeof contract.surface !== 'object') return null; + if (!Array.isArray(contract.tools) || !Array.isArray(contract.components)) return null; + if (!Array.isArray(contract.issues)) return null; + return contract as SurfaceContractView; +} + function renderContractSummary() { const active = readActiveContract(); const requested = active.surfacePlan; - const hostTools = active.capabilityNames.length ? active.capabilityNames.join(', ') : 'none'; - const components = active.componentNames?.length ? active.componentNames.join(', ') : 'none'; + const contract = currentSurfaceContractView; + const hostTools = contract + ? contract.tools.map((tool) => tool.name).join(', ') || 'none' + : active.capabilityNames.length ? active.capabilityNames.join(', ') : 'none'; + const components = contract + ? contract.components.map((component) => component.name).join(', ') || 'none' + : active.componentNames?.length ? active.componentNames.join(', ') : 'none'; + const toolCount = contract?.tools.length ?? active.capabilityNames.length; + const componentCount = contract?.components.length ?? active.componentNames?.length ?? 0; const validation = currentValidationSummary ?? 'pending'; const stream = currentStreamHealth ?? 'pending'; - const effective = currentEffectiveSurfacePlan ? planText(currentEffectiveSurfacePlan) : 'pending'; + const effectivePlan = contract?.surface.plan ?? currentEffectiveSurfacePlan; + const effective = effectivePlan ? planText(effectivePlan) : 'pending'; const provider = modelProviders.find((item) => item.id === active.modelProvider); const selectedModel = active.generationModel ?? provider?.defaults?.generationModel @@ -823,16 +843,16 @@ function renderContractSummary() { ?? provider?.defaults?.utilityModel ?? provider?.utilityModel ?? 'server default'; - inspectorStatusEl.textContent = currentEffectiveSurfacePlan ? 'effective' : 'pending'; + inspectorStatusEl.textContent = contract ? 'contract' : currentEffectiveSurfacePlan ? 'effective' : 'pending'; contractSummaryEl.innerHTML = ''; const rows = [ ['provider', 'Model provider', provider ? `${provider.name} · ${selectedModel}` : 'server default', provider ? 'neutral' : 'pending'], ['utility', 'Utility model', selectedUtility, provider ? 'neutral' : 'pending'], ['requested', 'Requested surface config', planText(requested), 'neutral'], - ['effective', 'Effective safety plan', effective, currentEffectiveSurfacePlan ? 'good' : 'pending'], - ['grants', 'Allowed host tools', `${active.capabilityNames.length}: ${hostTools}`, active.capabilityNames.length ? 'neutral' : 'pending'], - ['components', 'Trusted components', `${active.componentNames?.length ?? 0}: ${components}`, active.componentNames?.length ? 'good' : 'pending'], - ['runtime', 'Runtime', `${active.mode} · scripts ${active.scriptPolicy}`, active.scriptPolicy === 'allow' ? 'warn' : 'neutral'], + ['effective', 'Effective safety plan', effective, effectivePlan ? 'good' : 'pending'], + ['grants', 'Allowed host tools', `${toolCount}: ${hostTools}`, toolCount ? 'neutral' : 'pending'], + ['components', 'Trusted components', `${componentCount}: ${components}`, componentCount ? 'good' : 'pending'], + ['runtime', 'Runtime', `${contract?.surface.mode ?? active.mode} · scripts ${contract?.surface.scriptPolicy ?? active.scriptPolicy}`, (contract?.surface.scriptPolicy ?? active.scriptPolicy) === 'allow' ? 'warn' : 'neutral'], ['validation', 'Validation', validation, validation !== 'pending' && !validation.startsWith('0/') ? 'warn' : validation === 'pending' ? 'pending' : 'good'], ['stream', 'Stream diagnostics', stream, stream.startsWith('complete') ? 'good' : stream === 'pending' ? 'pending' : 'warn'], ['repair', 'Validation retry', active.repair?.enabled ? (currentRepairSummary ?? 'on') : 'off', active.repair?.enabled ? 'warn' : 'pending'], @@ -862,6 +882,7 @@ function clearEffectiveContractSummary() { currentValidationSummary = null; currentRepairSummary = null; currentStreamHealth = null; + currentSurfaceContractView = null; renderContractSummary(); } @@ -1584,6 +1605,7 @@ interface SandboxTarget { /** Fires when the server emits `/mode-upgraded`. Parent respawns; children no-op. */ onModeUpgrade?: () => void; onSurfacePlan?: (plan: SurfacePlan) => void; + onSurfaceContract?: (contract: SurfaceContractView) => void; onShape?: (shape: string) => void; onTokenOverrides?: (applied: Array<{ token: string; value: string }>) => void; onValidationSummary?: (value: unknown) => void; @@ -1625,6 +1647,19 @@ function applyLineTo(target: SandboxTarget, line: ProtocolLine, context: Surface } return; } + if (line.op === 'meta' && line.path === '/surface-contract') { + const contract = parseSurfaceContractView(line.value); + if (contract) { + target.onSurfaceContract?.(contract); + target.onLog( + 'op-meta', + `surface contract → ${contract.tools.length} tool${contract.tools.length === 1 ? '' : 's'}, ${contract.components.length} component${contract.components.length === 1 ? '' : 's'}`, + ); + } else { + target.onLog('op-meta', `surface contract → invalid ${JSON.stringify(line.value)}`); + } + return; + } if (line.op === 'meta' && line.path === '/shape') { const shape = typeof line.value === 'string' ? line.value : ''; if (shape) target.onShape?.(shape); @@ -1922,6 +1957,12 @@ async function streamGenerationInto(target: SandboxTarget, opts: StreamOptions): events.push({ kind: 'surface-plan', at: Date.now(), plan: surfacePlan }); } } + if (line.path === '/surface-contract') { + const contract = parseSurfaceContractView(line.value); + if (contract && target.recordEvents) { + events.push({ kind: 'surface-contract', at: Date.now(), contract }); + } + } if (line.path === '/shape' && typeof line.value === 'string') { shape = line.value; } @@ -1969,6 +2010,10 @@ function createParentTarget(active: ActiveContract): SandboxTarget { currentEffectiveSurfacePlan = plan; renderContractSummary(); }, + onSurfaceContract: (contract) => { + currentSurfaceContractView = contract; + renderContractSummary(); + }, onShape: (shape) => { currentShape = shape; renderContractSummary(); @@ -2116,6 +2161,7 @@ function replaySurface(envelope: SurfaceEnvelope) { currentStreamHealth = envelope.streamGraph ? `${envelope.streamGraph.health.complete ? 'complete' : 'open'} · missing=${envelope.streamGraph.health.missingDeclared.length} blocked=${envelope.streamGraph.health.blockedCount} retried=${envelope.streamGraph.health.repairedCount}` : null; + currentSurfaceContractView = null; acc.reset(); for (const line of envelope.protocolLines) { if (line.op !== 'meta') acc.applyDetailed(line); @@ -2157,6 +2203,7 @@ async function generate(prompt: string) { currentValidationSummary = null; currentRepairSummary = null; currentStreamHealth = null; + currentSurfaceContractView = null; respawn(currentDirectionId, active.mode, active); hideWelcome(); goBtn.disabled = true; @@ -2474,6 +2521,8 @@ function summarize(ev: DevtoolsEvent): string { return `sections=${ev.sections.length} missing=${ev.health.missingDeclared.length} skipped=${ev.health.skippedCount} retried=${ev.health.repairedCount}`; case 'surface-plan': return planText(ev.plan); + case 'surface-contract': + return `${ev.contract.tools?.length ?? 0} tool${ev.contract.tools?.length === 1 ? '' : 's'} · ${ev.contract.components?.length ?? 0} component${ev.contract.components?.length === 1 ? '' : 's'}`; case 'render': return `${ev.bytes.toLocaleString()} B`; case 'component-sync': diff --git a/apps/server/src/generate-route.test.ts b/apps/server/src/generate-route.test.ts index 5482522..0108bdd 100644 --- a/apps/server/src/generate-route.test.ts +++ b/apps/server/src/generate-route.test.ts @@ -206,9 +206,9 @@ test('api generate sends narrowed contract and stream meta shape through package assert.equal(policyRequest.stream, true); const policySystemText = policyRequest.system?.map((block) => block.text ?? '').join('\n') ?? ''; assert.match(policySystemText, /Search host-owned dinner data/); - assert.match(policySystemText, /Surface plan/); - assert.match(policySystemText, /Runtime: `declarative`/); - assert.match(policySystemText, /Data: `host-resource`/); + assert.match(policySystemText, /Surface contract/); + assert.match(policySystemText, /runtime=`declarative`/); + assert.match(policySystemText, /data=`host-resource`/); assert.doesNotMatch(policySystemText, /Rules for scripts/); const policyLines = policyBody @@ -216,9 +216,10 @@ test('api generate sends narrowed contract and stream meta shape through package .split(/\n/) .filter(Boolean) .map((raw) => JSON.parse(raw) as ProtocolLine); - assert.deepEqual(policyLines.slice(0, 4).map((line) => `${line.op} ${line.path}`), [ + assert.deepEqual(policyLines.slice(0, 5).map((line) => `${line.op} ${line.path}`), [ 'meta /surface-policy', 'meta /surface-plan', + 'meta /surface-contract', 'meta /status', 'set /screen', ]); @@ -231,6 +232,12 @@ test('api generate sends narrowed contract and stream meta shape through package persistence: 'replayable', }); assert.deepEqual((policyLines[1] as Extract).value, surfacePlan); + const policyContract = (policyLines[2] as Extract).value as { + tools?: Array<{ name: string }>; + components?: Array<{ name: string }>; + }; + assert.deepEqual(policyContract.tools?.map((tool) => tool.name), ['search']); + assert.deepEqual(policyContract.components, []); const ghostResponse = await fetch(`http://127.0.0.1:${appPort}/api/generate`, { method: 'POST', diff --git a/docs/adoption/debugging.md b/docs/adoption/debugging.md index 3d3009d..c04641e 100644 --- a/docs/adoption/debugging.md +++ b/docs/adoption/debugging.md @@ -141,6 +141,7 @@ These names are useful when maintaining Summon or writing a deeper adapter: | --- | --- | | `/surface-policy` | Host-owned public surface config selected for this run. | | `/surface-plan` | Host-owned compiled safety plan selected for this run. | +| `/surface-contract` | Host-owned compact view of the selected policy, narrowed tools/resources, trusted components, optional layout, and compile issues. | | `/shape` | Optional server-inferred response shape used to narrow direction exemplars. | | `/token-overrides` | Resolved direction token overrides, including applied and rejected entries. | | `/validation-summary` | Final grouped `ContractIssue` counts and examples. | @@ -184,14 +185,16 @@ and look for: the name, props, bounds, or registry compatibility failed. The event includes a stable code such as `bounds-invalid`, `unknown-component`, `props-invalid`, or `registry-missing`. +- `surface-contract` - host-owned compact contract view emitted by the server + for policy-backed generations. - `surface-plan` - host-owned compiled safety plan. - `stream-graph` - client-side stream diagnostics from `StreamGraph.snapshot()`. - `sandbox-fatal` - bootstrap detected an unsafe sandbox configuration. -Healthy interactive runs usually show `surface-plan`, `protocol-line`, `render`, -`component-sync`, `intent-emitted`, `intent-dispatched`, `state-pushed`, and -`stream-graph` in that order after the user interacts with a component-backed -UI. +Healthy interactive policy-backed runs usually show `surface-plan`, +`surface-contract`, `protocol-line`, `render`, `component-sync`, +`intent-emitted`, `intent-dispatched`, `state-pushed`, and `stream-graph` in +that order after the user interacts with a component-backed UI. ## Reading Contract Issues diff --git a/docs/adoption/integration.md b/docs/adoption/integration.md index e1e110f..c04602f 100644 --- a/docs/adoption/integration.md +++ b/docs/adoption/integration.md @@ -178,6 +178,28 @@ Common configs: Hosts choose the config before generation. The model may react to the compiled safety details, but it cannot widen what the host allowed. +### Surface Contract View + +When a server receives a `SurfacePolicy`, Summon also derives a read-only +`SurfaceContractView` from the compiled policy. This view gives prompts, +Devtools, replay/debug tooling, and host UIs one compact answer to "what can +this generated surface do?" + +The view includes: + +- The normalized host policy, compiled `SurfacePlan`, mode, and script policy. +- Narrowed host tools/resources, including triggers, schemas, state keys, result + schema, and surface data/authority. +- Narrowed trusted components, including prop schema, sizing, and surface + data/authority. +- Optional host layout slots. +- Any `ContractIssue[]` produced while compiling the surface policy. + +`SurfaceContractView` is diagnostic and prompt-facing only. It is not a JSON UI +schema and it is not an authority source. Enforcement still lives in +`SurfacePolicy` compilation, runtime validators, sandbox grants, +`PolicyEngine`, and component prop validation. + ## 4. Generate The Surface The generation server should use `@anarchitecture/summon-server` for the @@ -215,6 +237,13 @@ await runSurfaceGeneration({ }); ``` +For policy-backed runs, `runSurfaceGeneration()` emits host-owned metadata in +this order before model-authored output: + +1. `/surface-policy` - the normalized host policy. +2. `/surface-plan` - the compiled safety plan. +3. `/surface-contract` - the compact derived `SurfaceContractView`. + To enable validation retries, pass `repair: { enabled: true, provider, maxAttempts, maxTargets }`. The provider receives the compiled prompt blocks and a single replacement prompt; return one @@ -298,6 +327,7 @@ await consumeSurfaceStream(response.body!, { mode: compiledPolicy.mode, onMeta: (line) => { if (line.path === '/status') renderStatus(String(line.value)); + if (line.path === '/surface-contract') renderContractSummary(line.value); }, onGraph: (snapshot) => { events.push({ kind: 'stream-graph', at: Date.now(), health: snapshot.health }); diff --git a/docs/adoption/package-consumption.md b/docs/adoption/package-consumption.md index 66cb405..f28c574 100644 --- a/docs/adoption/package-consumption.md +++ b/docs/adoption/package-consumption.md @@ -90,9 +90,11 @@ defineReactComponent({ ```ts import { + compileSurfaceContractView, compileSurfacePolicy, createComponentRegistry, defineComponent, + type SurfaceContractView, } from '@anarchitecture/summon'; import { consumeSurfaceStream, @@ -116,6 +118,11 @@ contracts. mode and narrowed contracts that the server will enforce. Generation authority comes from the explicit surface config the host submits. +`compileSurfaceContractView(surfacePolicy, catalogs)` returns the same +policy-derived compact view that the server emits as `/surface-contract` for +policy-backed runs. Use it for previews, Devtools panels, and replay summaries; +do not use it as an enforcement source. + ```ts const componentRegistry = createComponentRegistry([ defineComponent({ diff --git a/docs/adoption/security.md b/docs/adoption/security.md index 6efdc19..79eb37b 100644 --- a/docs/adoption/security.md +++ b/docs/adoption/security.md @@ -55,8 +55,14 @@ with exact runtime, data, authority, persistence, and script policy for validation and diagnostics. Shape describes visual composition; posture describes the act; the surface config describes the public host decision. -Generated UI must not emit or widen `/surface-policy` or `/surface-plan`. -Those meta lines are host-owned diagnostics. +Summon also derives a `SurfaceContractView` from the compiled policy. It is a +compact diagnostic and prompt-facing view of the selected policy, narrowed host +tools/resources, narrowed trusted components, optional host layout slots, and +compile issues. It does not grant authority and it does not replace validators +or the `PolicyEngine`. + +Generated UI must not emit or widen `/surface-policy`, `/surface-plan`, or +`/surface-contract`. Those meta lines are host-owned diagnostics. ## Trusted Host Components diff --git a/examples/surface-gallery/src/main.ts b/examples/surface-gallery/src/main.ts index 4fd8852..0f0976f 100644 --- a/examples/surface-gallery/src/main.ts +++ b/examples/surface-gallery/src/main.ts @@ -15,7 +15,7 @@ import { type SurfaceStreamContext, } from '@anarchitecture/summon/browser'; import { createEventStore, type DevtoolsEvent } from '@anarchitecture/summon/devtools'; -import { SectionAccumulator, type ProtocolLine } from '@anarchitecture/summon/engine'; +import { SectionAccumulator, type ProtocolLine, type SurfaceContractView } from '@anarchitecture/summon/engine'; import { bootstrapSource, tokensSource } from '@anarchitecture/summon/assets'; import { createGalleryCapabilityRegistry } from './capabilities.js'; import { @@ -131,6 +131,7 @@ let surfaceRenderedDuringRun = false; let acceptedStructuralLines = 0; let skippedLines = 0; let blockedLines = 0; +let currentSurfaceContractView: SurfaceContractView | null = null; let approvalStack: HTMLElement | null = null; const pendingApprovalCards = new Map void>(); @@ -184,6 +185,7 @@ function renderPresetCards(): void { function selectPreset(id: string): void { selectedPreset = galleryPresets.find((preset) => preset.id === id) ?? findPreset(id); activeTokensSourceOverride = null; + currentSurfaceContractView = null; for (const card of presetList.querySelectorAll('.preset-card')) { card.classList.toggle('active', card.dataset.presetId === selectedPreset.id); } @@ -290,6 +292,7 @@ async function generateSelectedSurface(): Promise { accumulator.reset(); events.clear(); hostMessages.length = 0; + currentSurfaceContractView = null; resetCounters(); respawnSandbox(); generationInFlight = true; @@ -398,6 +401,14 @@ function handleMeta(line: Extract): void { if (line.path === '/protocol-skip') { skippedLines += 1; } + if (line.path === '/surface-contract') { + const contract = parseSurfaceContractView(line.value); + if (contract) { + currentSurfaceContractView = contract; + events.push({ kind: 'surface-contract', at: Date.now(), contract }); + renderContract(); + } + } if (line.path === '/validation-blocked') { blockedLines += 1; selectInspectorTab('stream'); @@ -421,12 +432,17 @@ function handleMeta(line: Extract): void { } function renderContract(): void { + const contract = currentSurfaceContractView; const componentNames = policyComponents(selectedPreset.surfacePolicy); const grantNames = policyGrants(selectedPreset.surfacePolicy); - const components = componentNames.length + const components = contract + ? contract.components.map((component) => component.name).join(', ') || 'none' + : componentNames.length ? componentNames.join(', ') : 'none'; - const allowedHostTools = grantNames.length + const allowedHostTools = contract + ? contract.tools.map((tool) => tool.name).join(', ') || 'none' + : grantNames.length ? grantNames.join(', ') : 'none'; const policy = policyText(selectedPreset.surfacePolicy); @@ -442,8 +458,10 @@ function renderContract(): void { ?? 'server default'; providerSummaryEl.textContent = provider ? `${provider.name} - ${generationModel}` : modelProviderLabel; surfacePolicyPill.textContent = centerPolicyText(selectedPreset); - surfaceToolsPill.textContent = `${grantNames.length} host tool${grantNames.length === 1 ? '' : 's'}`; - surfaceComponentsPill.textContent = `${componentNames.length} component${componentNames.length === 1 ? '' : 's'}`; + const toolCount = contract?.tools.length ?? grantNames.length; + const componentCount = contract?.components.length ?? componentNames.length; + surfaceToolsPill.textContent = `${toolCount} host tool${toolCount === 1 ? '' : 's'}`; + surfaceComponentsPill.textContent = `${componentCount} component${componentCount === 1 ? '' : 's'}`; welcomeTitle.textContent = selectedPreset.title; welcomeDetail.textContent = selectedPreset.description; contractSummary.innerHTML = ''; @@ -451,7 +469,10 @@ function renderContract(): void { ['provider', 'Model provider', provider ? `${provider.name} - ${generationModel}` : modelProviderLabel], ['utility', 'Utility model', utilityModel], ['policy', 'Surface config', policy], - ['tier', 'Surface type', selectedPreset.surfacePolicy.tier], + ['tier', 'Surface type', contract?.surface.policy.tier ?? selectedPreset.surfacePolicy.tier], + ...(contract + ? [['runtime', 'Runtime', `${contract.surface.mode} - scripts ${contract.surface.scriptPolicy}`] as [string, string, string]] + : []), ['grants', 'Allowed host tools', allowedHostTools], ['components', 'Trusted components', components], ...(selectedPreset.ghost @@ -715,6 +736,15 @@ function centerPolicyText(preset: GalleryPreset): string { return `${preset.surfacePolicy.tier} / ${preset.surfacePolicy.purpose ?? 'surface'}`; } +function parseSurfaceContractView(value: unknown): SurfaceContractView | null { + if (!value || typeof value !== 'object') return null; + const contract = value as Partial; + if (!contract.surface || typeof contract.surface !== 'object') return null; + if (!Array.isArray(contract.tools) || !Array.isArray(contract.components)) return null; + if (!Array.isArray(contract.issues)) return null; + return contract as SurfaceContractView; +} + function describeEvent(event: DevtoolsEvent): string { switch (event.kind) { case 'protocol-line': @@ -731,6 +761,8 @@ function describeEvent(event: DevtoolsEvent): string { return `component ${event.code}: ${event.reason}`; case 'stream-lifecycle': return `stream ${event.phase}${event.ok === undefined ? '' : event.ok ? ' ok' : ' error'}`; + case 'surface-contract': + return `contract ${(event.contract.tools?.length ?? 0)} tools / ${(event.contract.components?.length ?? 0)} components`; default: return event.kind; } diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts index 71fa378..f4236cb 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -17,6 +17,7 @@ export type { ProtocolParseErrorEvent, StreamLifecycleEvent, StreamGraphEvent, + SurfaceContractEvent, SurfacePlanEvent, RenderEvent, ComponentSyncEvent, diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts index 8312f7a..30fe8fc 100644 --- a/packages/devtools/src/types.ts +++ b/packages/devtools/src/types.ts @@ -137,6 +137,17 @@ export interface SurfacePlanEvent extends BaseEvent { }; } +export interface SurfaceContractEvent extends BaseEvent { + kind: 'surface-contract'; + contract: { + surface?: unknown; + tools?: unknown[]; + components?: unknown[]; + layout?: unknown; + issues?: unknown[]; + }; +} + /** Host pushed full HTML into the sandbox via SUMMON_RENDER. */ export interface RenderEvent extends BaseEvent { kind: 'render'; @@ -180,6 +191,7 @@ export type DevtoolsEvent = | StreamLifecycleEvent | StreamGraphEvent | SurfacePlanEvent + | SurfaceContractEvent | RenderEvent | ComponentSyncEvent | ComponentErrorEvent; diff --git a/packages/engine/src/capability-contract.ts b/packages/engine/src/capability-contract.ts index 01216a1..b9d6a51 100644 --- a/packages/engine/src/capability-contract.ts +++ b/packages/engine/src/capability-contract.ts @@ -5,6 +5,17 @@ export interface CapabilityStateKeys { loading?: string; data?: string; error?: string; + empty?: string; +} + +export interface ResourceStateKeys extends Required> { + empty?: string; +} + +export interface ActionStateKeys { + pending: string; + done: string; + error: string; } export interface CapabilityTriggerSpec { @@ -56,7 +67,7 @@ export const CAPABILITY_BINDING_SPECS: CapabilityBindingSpec[] = [ attribute: 'data-summon-resource', value: '', description: - 'Declares a resource scope. Descendants may bind `$alias.loading`, `$alias.data`, and `$alias.error`.', + 'Declares a resource scope. Descendants may bind `$alias.loading`, `$alias.data`, `$alias.error`, and optional `$alias.empty`.', }, { attribute: 'data-summon-resource-as', @@ -146,7 +157,7 @@ export function defaultTriggersForKind(kind: CapabilityKind = 'action'): Capabil export function hasCompleteResourceStateKeys( keys: CapabilityStateKeys | undefined, -): keys is Required> { +): keys is ResourceStateKeys { return Boolean(keys?.loading && keys.data && keys.error); } @@ -174,7 +185,7 @@ ${bindingRows} #### Data resource scopes -Data resources expose host-owned lifecycle state. Wrap a data resource UI in \`data-summon-resource=""\`, optionally rename it with \`data-summon-resource-as=""\`, then bind \`$alias.loading\`, \`$alias.data\`, and \`$alias.error\`. A resource trigger always emits the resource's intent name; the PolicyEngine remains the execution boundary. +Data resources expose host-owned lifecycle state. Wrap a data resource UI in \`data-summon-resource=""\`, optionally rename it with \`data-summon-resource-as=""\`, then bind \`$alias.loading\`, \`$alias.data\`, \`$alias.error\`, and optional \`$alias.empty\`. A resource trigger always emits the resource's intent name; the PolicyEngine remains the execution boundary. Example: diff --git a/packages/engine/src/contracts.ts b/packages/engine/src/contracts.ts index f3e656c..23ee8f5 100644 --- a/packages/engine/src/contracts.ts +++ b/packages/engine/src/contracts.ts @@ -7,6 +7,7 @@ import { buildLayoutBlock, buildOverrideBlock, buildPosturesBlock, + buildSurfaceContractBlock, type CapabilityPack, type ComponentPack, type DirectionInput, @@ -25,6 +26,7 @@ import { hasCompleteResourceStateKeys, } from './capability-contract.js'; import { formatTokenContract } from './token-contract.js'; +import type { SurfaceContractView } from './surface-contract.js'; import { buildSurfacePlanBlock, surfacePlanScriptPolicy, @@ -143,6 +145,7 @@ export interface SystemContractInput { tokenOverrides?: TokenOverride[]; postures?: PostureRegistry | null; surfacePlan?: SurfacePlan | null; + surfaceContract?: SurfaceContractView | null; activeTokensCss?: string | null; } @@ -150,6 +153,7 @@ export interface CompiledSystemContracts { promptBlocks: ContractPromptBlock[]; validationContext: ValidationContext; startupLines: ProtocolLine[]; + surfaceContract?: SurfaceContractView; issues: ContractIssue[]; } @@ -196,7 +200,7 @@ export function hintsForContractIssue(issue: ContractIssue): string[] { case 'bad-attr-binding-placement': return ['Use only safe data-summon-attr-* bindings on supported elements.']; case 'host-owned-meta': - return ['Remove host-owned meta lines; the host emits /surface-policy and /surface-plan before model output.']; + return ['Remove host-owned meta lines; the host emits /surface-policy, /surface-plan, and /surface-contract before model output.']; case 'surface-policy-invalid': case 'surface-policy-unknown-grant': case 'surface-policy-unknown-component': @@ -280,11 +284,18 @@ export function compileCapabilityContract( : defaultTriggersForKind(intent.kind ?? 'action'), }; if (intent.stateKeys) capability.stateKeys = intent.stateKeys; + if (intent.actionStateKeys) capability.actionStateKeys = intent.actionStateKeys; if (intent.surface) capability.surface = intent.surface; if (intent.kind === 'resource' && hasCompleteResourceStateKeys(intent.stateKeys)) { initialState[intent.stateKeys.loading] = false; initialState[intent.stateKeys.data] = intent.defaultData ?? null; initialState[intent.stateKeys.error] = null; + if (intent.stateKeys.empty) initialState[intent.stateKeys.empty] = false; + } + if ((intent.kind ?? 'action') === 'action' && intent.actionStateKeys) { + initialState[intent.actionStateKeys.pending] = false; + initialState[intent.actionStateKeys.done] = false; + initialState[intent.actionStateKeys.error] = null; } return capability; }); @@ -381,19 +392,26 @@ export function compileSystemContracts( }); } - if (input.surfacePlan) { + const activeSurfacePlan = input.surfaceContract?.surface.plan ?? input.surfacePlan ?? null; + if (input.surfaceContract) { + promptBlocks.push({ + id: 'surface-contract', + text: buildSurfaceContractBlock(input.surfaceContract), + cache: 'ephemeral', + }); + } else if (activeSurfacePlan) { promptBlocks.push({ id: 'surface-plan', - text: buildSurfacePlanBlock(input.surfacePlan), + text: buildSurfacePlanBlock(activeSurfacePlan), cache: 'ephemeral', }); } const requestedScriptPolicy = input.scriptPolicy ?? - (input.surfacePlan - ? surfacePlanScriptPolicy(input.surfacePlan) + (activeSurfacePlan + ? surfacePlanScriptPolicy(activeSurfacePlan) : 'forbid'); - const hasScriptedPlan = input.surfacePlan?.runtime === 'scripted'; + const hasScriptedPlan = activeSurfacePlan?.runtime === 'scripted'; if (hasScriptedPlan && requestedScriptPolicy !== 'allow') { issues.push(contractIssue({ source: 'system', @@ -441,13 +459,14 @@ export function compileSystemContracts( promptBlocks, issues, startupLines, + surfaceContract: input.surfaceContract ?? undefined, validationContext: { mode: input.mode, allowedIntents: capability.intentNames, capabilities: capability.validationCapabilities, components: component.validationComponents, scriptPolicy, - surfacePlan: input.surfacePlan ?? undefined, + surfacePlan: activeSurfacePlan ?? undefined, definedTokens: activeTokensCss ? parseDefinedTokens(activeTokensCss) : undefined, }, }; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index da88e27..973592b 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -41,6 +41,7 @@ export { buildComponentsBlock, buildOverrideBlock, buildPosturesBlock, + buildSurfaceContractBlock, } from './prompt.js'; export type { Exemplar, @@ -182,6 +183,18 @@ export type { SurfacePolicy, SurfaceTier, } from './surface-policy.js'; +export { + compileSurfaceContractView, + surfaceContractViewFromCompiledPolicy, +} from './surface-contract.js'; +export type { + CompileSurfaceContractViewOptions, + SurfaceContractComponent, + SurfaceContractLayout, + SurfaceContractSurface, + SurfaceContractTool, + SurfaceContractView, +} from './surface-contract.js'; export { createProtocolHardener } from './protocol-hardener.js'; export type { ProtocolHardener, diff --git a/packages/engine/src/prompt.ts b/packages/engine/src/prompt.ts index dd06238..9c3e855 100644 --- a/packages/engine/src/prompt.ts +++ b/packages/engine/src/prompt.ts @@ -10,12 +10,15 @@ import { formatTokenContract, OPT_OUT_GROUPS } from './token-contract.js'; import type { DirectionOpts } from './direction-validator.js'; import { + type ActionStateKeys, defaultTriggersForKind, formatCapabilityProtocolContract, type CapabilityKind, type CapabilityStateKeys, type CapabilityTrigger, + type ResourceStateKeys, } from './capability-contract.js'; +import type { SurfaceContractView } from './surface-contract.js'; import type { ComponentSurface, CapabilitySurface } from './surface-plan.js'; export interface Exemplar { @@ -305,6 +308,71 @@ export function buildOverrideBlock(overrides: TokenOverride[]): string { return lines.join('\n'); } +export function buildSurfaceContractBlock(contract: SurfaceContractView): string { + const { surface } = contract; + const toolLines = contract.tools.length + ? contract.tools.map((tool) => { + const stateKeys = tool.stateKeys + ? `; state keys ${formatStateKeys(tool.stateKeys)}` + : ''; + const actionState = tool.actionStateKeys + ? `; action state ${formatActionStateKeys(tool.actionStateKeys)}` + : ''; + const result = tool.resultSchema ? `; result \`${tool.resultSchema}\`` : ''; + const defaultData = tool.defaultDataShape ? `; default \`${tool.defaultDataShape}\`` : ''; + return `- \`${tool.name}\` (${tool.kind}) — ${tool.description} Triggers: ${tool.triggers.join(', ')}; args \`${tool.argsSchema}\`; state \`${tool.stateShape}\`${stateKeys}${actionState}${result}${defaultData}; surface data=${tool.surface.data}, authority=${tool.surface.authority}`; + }).join('\n') + : '- none'; + const componentLines = contract.components.length + ? contract.components.map((component) => { + const sizing = component.sizing + ? `; sizing ${[ + component.sizing.width ? `width=${component.sizing.width}` : '', + component.sizing.height ? `height=${component.sizing.height}` : '', + component.sizing.description ?? '', + ].filter(Boolean).join(', ')}` + : ''; + return `- \`${component.name}\` — ${component.description} Props: \`${component.propsSchema}\`; surface data=${component.surface.data}, authority=${component.surface.authority}${sizing}`; + }).join('\n') + : '- none'; + const layoutLines = contract.layout + ? contract.layout.slots + .map((slot) => `- \`${slot.id}\` — ${slot.purpose}`) + .join('\n') + : '- none'; + const issueLine = contract.issues.length + ? `${contract.issues.length} host compile issue${contract.issues.length === 1 ? '' : 's'}; do not widen the surface to work around them.` + : 'none'; + + return `## Surface contract — host-owned boundaries + +This is a compact, read-only view of the host-selected \`SurfacePolicy\`. It tells you what this generated surface can do. It is not a JSON UI schema: you still generate rich freeform HTML/CSS inside these typed boundaries. + +Do not emit \`/surface-contract\`, \`/surface-policy\`, or \`/surface-plan\` meta lines. The host owns those lines and enforcement still lives in the runtime validators, PolicyEngine, sandbox grants, and component prop validation. + +### Surface + +- Policy: tier=\`${surface.policy.tier}\`, purpose=\`${surface.policy.purpose}\`, persistence=\`${surface.policy.persistence}\` +- Plan: purpose=\`${surface.plan.purpose}\`, runtime=\`${surface.plan.runtime}\`, data=\`${surface.plan.data}\`, authority=\`${surface.plan.authority}\`, persistence=\`${surface.plan.persistence}\` +- Mode: \`${surface.mode}\`; scripts \`${surface.scriptPolicy}\` + +### Tools + +${toolLines} + +### Trusted components + +${componentLines} + +### Host layout + +${layoutLines} + +### Compile issues + +${issueLine}`; +} + function buildDirectionAddendum( opts: DirectionOpts | undefined, liveOpportunistic: string[] | undefined @@ -349,6 +417,7 @@ export interface IntentSpec { kind?: CapabilityKind; triggers?: CapabilityTrigger[]; stateKeys?: CapabilityStateKeys; + actionStateKeys?: ActionStateKeys; surface?: CapabilitySurface; resultSchema?: string; defaultDataShape?: string; @@ -357,7 +426,7 @@ export interface IntentSpec { export interface DataResourceSpec extends IntentSpec { kind: 'resource'; - stateKeys: Required>; + stateKeys: ResourceStateKeys; resultSchema?: string; defaultDataShape?: string; defaultData?: unknown; @@ -430,8 +499,11 @@ export function buildCapabilitiesBlock( const stateKeys = i.stateKeys ? `\n State keys: ${formatStateKeys(i.stateKeys)}` : ''; + const actionStateKeys = i.actionStateKeys + ? `\n Action state: ${formatActionStateKeys(i.actionStateKeys)}` + : ''; const surface = i.surface ? `\n Surface: ${formatSurface(i.surface)}` : ''; - return `- \`${i.name}(${i.argsSchema})\` — ${i.description}\n Triggers: ${triggers}\n State update: \`${i.stateShape}\`${stateKeys}${surface}`; + return `- \`${i.name}(${i.argsSchema})\` — ${i.description}\n Triggers: ${triggers}\n State update: \`${i.stateShape}\`${stateKeys}${actionStateKeys}${surface}`; }; const actionsList = actions @@ -553,13 +625,15 @@ Dead buttons are worse than no buttons. When in doubt, leave it out. Only the intents listed above exist. Any concept that isn't in the intent list does not exist — don't add controls that imply capabilities you don't have. When in doubt, route the user-visible action through the closest matching intent or drop the control. -Data resources expose host-owned loading/data/error state. Use \`mount\` only for initial read-oriented loads granted by the resource; use \`submit\` for forms and \`click\` only when the resource grants a click trigger. Bind \`$alias.loading\` for busy UI, \`$alias.error\` for host errors, and \`$alias.data\` for validated result data. +Data resources expose host-owned loading/data/error state and may expose \`$alias.empty\` when the host declares an empty-state key. Use \`mount\` only for initial read-oriented loads granted by the resource; use \`submit\` for forms and \`click\` only when the resource grants a click trigger. Bind \`$alias.loading\` for busy UI, \`$alias.error\` for host errors, \`$alias.data\` for validated result data, and \`$alias.empty\` only for real no-results copy after a successful host result. -Default data is real host state. A data resource starts at \`{loading:false, data:defaultData ?? null, error:null}\`, and loading/error/invalid-result states keep the data value at \`defaultData ?? null\`. Never hallucinate fetched rows, profiles, images, or counts before a successful data resource result. Wrap result UI in \`data-summon-show="$alias.data"\`; for arrays, put \`data-summon-foreach="$alias.data"\` on the result list so no rows render until the host data exists. +Default data is real host state. A data resource starts at \`{loading:false, data:defaultData ?? null, error:null, empty:false when declared}\`, and loading/error/invalid-result states keep the data value at \`defaultData ?? null\` with \`empty:false\`. Never hallucinate fetched rows, profiles, images, or counts before a successful data resource result. Wrap result UI in \`data-summon-show="$alias.data"\`; for arrays, put \`data-summon-foreach="$alias.data"\` on the result list so no rows render until the host data exists. Render "no results" from \`$alias.empty\`, not from missing or pre-load data. + +Controlled actions expose host-owned pending/done/error keys when listed under Action state. Use \`pending\` to disable or mark the triggering control busy, show \`error\` as host failure text, and show \`done\` only for useful success confirmation. Do not fake completed, approved, or failed states in local markup. ### Initial state -Action-owned state starts empty until an action intent fires. Data-resource lifecycle keys start from the default state described above. Render defensively: show an empty-state message or a form before data exists, never placeholder fetched data. \`data-summon-show\` reads as falsy for missing keys, so wrapping action result UI in \`data-summon-show=""\` is a clean empty-state default.${patternsBlock}`; +Action-owned state starts empty unless the host declares controlled action state, in which case pending/done/error start false/false/null. Data-resource lifecycle keys start from the default state described above. Render defensively: show an empty-state message only from declared empty state or a form before data exists, never placeholder fetched data. \`data-summon-show\` reads as falsy for missing keys, so wrapping action result UI in \`data-summon-show=""\` is a clean empty-state default.${patternsBlock}`; } export function buildComponentsBlock(pack: ComponentPack | null | undefined): string { @@ -637,9 +711,14 @@ function formatStateKeys(keys: CapabilityStateKeys): string { if (keys.loading) parts.push(`loading=${keys.loading}`); if (keys.data) parts.push(`data=${keys.data}`); if (keys.error) parts.push(`error=${keys.error}`); + if (keys.empty) parts.push(`empty=${keys.empty}`); return parts.length ? parts.join(', ') : 'none'; } +function formatActionStateKeys(keys: ActionStateKeys): string { + return `pending=${keys.pending}, done=${keys.done}, error=${keys.error}`; +} + function formatSurface(surface: CapabilitySurface): string { const parts: string[] = []; if (surface.data) parts.push(`data=${surface.data}`); diff --git a/packages/engine/src/runtime-validator/binding-rules.ts b/packages/engine/src/runtime-validator/binding-rules.ts index 970cae2..90accf3 100644 --- a/packages/engine/src/runtime-validator/binding-rules.ts +++ b/packages/engine/src/runtime-validator/binding-rules.ts @@ -105,6 +105,8 @@ export function scanResourceAndAttributeBindings( hasLoadingBinding: false, hasErrorBinding: false, hasDataBinding: false, + hasEmptyState: Boolean(capability.stateKeys.empty), + hasEmptyBinding: false, } : undefined; if (usage) resourceUsages.push(usage); @@ -259,6 +261,13 @@ function recordResourceUsage( if (isDataResultBinding(attr) && referencesResourceSlot(value, usage.alias, 'data')) { usage.hasDataBinding = true; } + if ( + usage.hasEmptyState && + isVisibleStateBinding(attr) && + referencesResourceSlot(value, usage.alias, 'empty') + ) { + usage.hasEmptyBinding = true; + } } } @@ -288,7 +297,7 @@ function isDataResultBinding(attr: string): boolean { function referencesResourceSlot( value: string, alias: string, - slot: 'loading' | 'data' | 'error', + slot: 'loading' | 'data' | 'error' | 'empty', ): boolean { const path = `$${alias}.${slot}`; return value.trim() === path || value.trim().startsWith(`${path}.`); diff --git a/packages/engine/src/runtime-validator/protocol.ts b/packages/engine/src/runtime-validator/protocol.ts index 0ea5fd6..9e25fd4 100644 --- a/packages/engine/src/runtime-validator/protocol.ts +++ b/packages/engine/src/runtime-validator/protocol.ts @@ -7,7 +7,7 @@ import type { ContractIssue } from '../contracts.js'; const SECTION_ID_RE = /^[a-z][a-z0-9-]{0,19}$/; const META_PATH_RE = /^\/[a-z][a-z0-9-/]{0,119}$/; -const HOST_OWNED_META_PATHS = new Set(['/surface-policy', '/surface-plan']); +const HOST_OWNED_META_PATHS = new Set(['/surface-policy', '/surface-plan', '/surface-contract']); export function validateProtocolLine( line: ProtocolLine, diff --git a/packages/engine/src/runtime-validator/types.ts b/packages/engine/src/runtime-validator/types.ts index 7d10593..5e287eb 100644 --- a/packages/engine/src/runtime-validator/types.ts +++ b/packages/engine/src/runtime-validator/types.ts @@ -1,4 +1,5 @@ import type { + ActionStateKeys, CapabilityKind, CapabilityStateKeys, CapabilityTrigger, @@ -23,6 +24,7 @@ export interface ValidationCapability { kind?: CapabilityKind; triggers?: CapabilityTrigger[]; stateKeys?: CapabilityStateKeys; + actionStateKeys?: ActionStateKeys; surface?: CapabilitySurface; } @@ -36,6 +38,7 @@ export interface RuntimeCapability { kind: CapabilityKind; triggers: Set; stateKeys?: CapabilityStateKeys; + actionStateKeys?: ActionStateKeys; surface?: CapabilitySurface; } @@ -46,6 +49,8 @@ export interface ResourceUsage { hasLoadingBinding: boolean; hasErrorBinding: boolean; hasDataBinding: boolean; + hasEmptyState: boolean; + hasEmptyBinding: boolean; } export interface ResourceScope { diff --git a/packages/engine/src/surface-contract.ts b/packages/engine/src/surface-contract.ts new file mode 100644 index 0000000..c779ed0 --- /dev/null +++ b/packages/engine/src/surface-contract.ts @@ -0,0 +1,164 @@ +import { + defaultTriggersForKind, + type ActionStateKeys, + type CapabilityKind, + type CapabilityStateKeys, + type CapabilityTrigger, +} from './capability-contract.js'; +import type { ContractIssue } from './contracts.js'; +import type { + CapabilityPack, + ComponentPack, + ComponentSizing, + ScriptPolicy, + SummonLayout, +} from './prompt.js'; +import { + compileSurfacePolicy, + type CompileSurfacePolicyOptions, + type CompiledSurfacePolicy, + type NormalizedSurfacePolicy, + type SurfacePolicy, +} from './surface-policy.js'; +import type { + SurfaceAuthority, + SurfaceData, + SurfacePlan, + SurfacePlanMode, +} from './surface-plan.js'; + +export interface SurfaceContractSurface { + policy: NormalizedSurfacePolicy; + plan: SurfacePlan; + mode: SurfacePlanMode; + scriptPolicy: ScriptPolicy; +} + +export interface SurfaceContractTool { + name: string; + kind: CapabilityKind; + description: string; + triggers: CapabilityTrigger[]; + argsSchema: string; + stateShape: string; + stateKeys?: CapabilityStateKeys; + actionStateKeys?: ActionStateKeys; + resultSchema?: string; + defaultDataShape?: string; + surface: { + data: SurfaceData; + authority: SurfaceAuthority; + }; +} + +export interface SurfaceContractComponent { + name: string; + description: string; + propsSchema: string; + sizing?: ComponentSizing; + surface: { + data: SurfaceData; + authority: SurfaceAuthority; + }; +} + +export interface SurfaceContractLayout { + id: string; + slots: Array<{ + id: string; + purpose: string; + }>; +} + +export interface SurfaceContractView { + surface: SurfaceContractSurface; + tools: SurfaceContractTool[]; + components: SurfaceContractComponent[]; + layout: SurfaceContractLayout | null; + issues: ContractIssue[]; +} + +export interface CompileSurfaceContractViewOptions extends CompileSurfacePolicyOptions { + layout?: SummonLayout | SurfaceContractLayout | null; +} + +export function compileSurfaceContractView( + policy: SurfacePolicy | unknown, + options: CompileSurfaceContractViewOptions = {}, +): SurfaceContractView { + const compiled = compileSurfacePolicy(policy, { + capabilities: options.capabilities, + components: options.components, + }); + return surfaceContractViewFromCompiledPolicy(compiled, options.layout ?? null); +} + +export function surfaceContractViewFromCompiledPolicy( + compiledPolicy: CompiledSurfacePolicy, + layout?: SummonLayout | SurfaceContractLayout | null, +): SurfaceContractView { + return { + surface: { + policy: compiledPolicy.policy, + plan: compiledPolicy.surfacePlan, + mode: compiledPolicy.mode, + scriptPolicy: compiledPolicy.scriptPolicy, + }, + tools: formatTools(compiledPolicy.capabilities), + components: formatComponents(compiledPolicy.components), + layout: formatLayout(layout ?? null), + issues: compiledPolicy.issues, + }; +} + +function formatTools(pack: CapabilityPack | null): SurfaceContractTool[] { + return (pack?.intents ?? []).map((intent) => { + const kind = intent.kind ?? 'action'; + const tool: SurfaceContractTool = { + name: intent.name, + kind, + description: intent.description, + triggers: intent.triggers?.length + ? [...intent.triggers] + : defaultTriggersForKind(kind), + argsSchema: intent.argsSchema, + stateShape: intent.stateShape, + surface: { + data: intent.surface?.data ?? (kind === 'resource' ? 'host-resource' : 'embedded'), + authority: intent.surface?.authority ?? (kind === 'resource' ? 'read' : 'host-action'), + }, + }; + if (intent.stateKeys) tool.stateKeys = { ...intent.stateKeys }; + if (intent.actionStateKeys) tool.actionStateKeys = { ...intent.actionStateKeys }; + if (intent.resultSchema) tool.resultSchema = intent.resultSchema; + if (intent.defaultDataShape) tool.defaultDataShape = intent.defaultDataShape; + return tool; + }); +} + +function formatComponents(pack: ComponentPack | null): SurfaceContractComponent[] { + return (pack?.components ?? []).map((component) => { + const formatted: SurfaceContractComponent = { + name: component.name, + description: component.description, + propsSchema: component.propsSchema, + surface: { + data: component.surface?.data ?? 'embedded', + authority: component.surface?.authority ?? 'none', + }, + }; + if (component.sizing) formatted.sizing = { ...component.sizing }; + return formatted; + }); +} + +function formatLayout(layout: SummonLayout | SurfaceContractLayout | null): SurfaceContractLayout | null { + if (!layout) return null; + return { + id: layout.id, + slots: layout.slots.map((slot) => ({ + id: slot.id, + purpose: slot.purpose, + })), + }; +} diff --git a/packages/engine/test/contracts.test.ts b/packages/engine/test/contracts.test.ts index 750f440..bc1d109 100644 --- a/packages/engine/test/contracts.test.ts +++ b/packages/engine/test/contracts.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { compileDirectionContract, + compileSurfaceContractView, compileSystemContracts, compileTokenContract, deriveSurfacePlanControls, @@ -226,6 +227,62 @@ test('system compiler includes a host-owned surface plan block', () => { assert.match(surfaceBlock?.text ?? '', /Do not emit a `\/surface-plan` meta line/); }); +test('system compiler includes compact surface contract view without dropping detail blocks', () => { + const capabilities: CapabilityPack = { + intents: [ + { + name: 'search', + description: 'Search host data.', + argsSchema: '{query: string}', + stateShape: '{loading: boolean, results: unknown[]}', + kind: 'resource', + triggers: ['submit'], + stateKeys: { loading: 'loading', data: 'results', error: 'error' }, + resultSchema: 'unknown[]', + surface: { data: 'host-resource', authority: 'read' }, + }, + ], + }; + const components: ComponentPack = { + components: [ + { + name: 'MetricCard', + description: 'Displays a KPI card.', + propsSchema: '{label: string, value: string}', + surface: { data: 'embedded', authority: 'none' }, + }, + ], + }; + const surfaceContract = compileSurfaceContractView({ + tier: 'declarative', + purpose: 'explore', + grants: ['search'], + components: ['MetricCard'], + }, { capabilities, components }); + const compiled = compileSystemContracts({ + mode: surfaceContract.surface.mode, + surfaceContract, + capabilities: surfaceContract.tools.length ? capabilities : null, + components, + scriptPolicy: surfaceContract.surface.scriptPolicy, + }); + + assert.deepEqual( + compiled.promptBlocks.map((block) => block.id), + ['fixed', 'surface-contract', 'capabilities', 'components'], + ); + const surfaceBlock = compiled.promptBlocks.find((block) => block.id === 'surface-contract'); + assert.match(surfaceBlock?.text ?? '', /compact, read-only view/); + assert.match(surfaceBlock?.text ?? '', /freeform HTML\/CSS/); + assert.match(surfaceBlock?.text ?? '', /Do not emit `\/surface-contract`, `\/surface-policy`, or `\/surface-plan`/); + assert.match(surfaceBlock?.text ?? '', /`search` \(resource\)/); + assert.match(surfaceBlock?.text ?? '', /`MetricCard`/); + assert.equal(compiled.promptBlocks.some((block) => block.id === 'surface-plan'), false); + assert.match(compiled.promptBlocks.find((block) => block.id === 'capabilities')?.text ?? '', /Available data resources/); + assert.match(compiled.promptBlocks.find((block) => block.id === 'components')?.text ?? '', /Component islands/); + assert.deepEqual(compiled.validationContext.surfacePlan, surfaceContract.surface.plan); +}); + test('surface plan normalization and suggestions are stable', () => { assert.deepEqual(normalizeSurfacePlan({ purpose: 'operate', diff --git a/packages/engine/test/runtime-validator-protocol.test.ts b/packages/engine/test/runtime-validator-protocol.test.ts index 5d3f34d..e79fd29 100644 --- a/packages/engine/test/runtime-validator-protocol.test.ts +++ b/packages/engine/test/runtime-validator-protocol.test.ts @@ -50,6 +50,13 @@ test('blocks generated host-owned surface meta paths', () => { )), ['host-owned-meta'], ); + assert.deepEqual( + codes(validateProtocolLine( + { op: 'meta', path: '/surface-contract', value: {} }, + baseContext, + )), + ['host-owned-meta'], + ); }); test('allows safe static markup', () => { diff --git a/packages/engine/test/surface-contract.test.ts b/packages/engine/test/surface-contract.test.ts new file mode 100644 index 0000000..0639317 --- /dev/null +++ b/packages/engine/test/surface-contract.test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + compileSurfaceContractView, + compileSurfacePolicy, + surfaceContractViewFromCompiledPolicy, + type CapabilityPack, + type ComponentPack, +} from '../src/index.ts'; + +const capabilities: CapabilityPack = { + intents: [ + { + name: 'search', + description: 'Search host records.', + argsSchema: '{query: string}', + stateShape: '{loading: boolean, results: SearchResult[], error: string | null, empty: boolean}', + kind: 'resource', + triggers: ['submit', 'mount'], + stateKeys: { + loading: 'searchLoading', + data: 'searchResults', + error: 'searchError', + empty: 'searchEmpty', + }, + resultSchema: 'SearchResult[]', + defaultDataShape: '[]', + surface: { data: 'host-resource', authority: 'read' }, + }, + { + name: 'choose', + description: 'Save a choice.', + argsSchema: '{id: string}', + stateShape: '{choiceId: string}', + kind: 'action', + actionStateKeys: { + pending: 'choosePending', + done: 'chooseDone', + error: 'chooseError', + }, + surface: { authority: 'host-action' }, + }, + { + name: 'analysis', + description: 'Run worker analysis.', + argsSchema: '{}', + stateShape: '{}', + kind: 'resource', + surface: { data: 'worker', authority: 'read' }, + }, + ], +}; + +const components: ComponentPack = { + components: [ + { + name: 'MetricCard', + description: 'Trusted metric card.', + propsSchema: '{label: string, value: string}', + surface: { data: 'embedded', authority: 'none' }, + sizing: { width: '320px', height: '112px' }, + examples: [{ name: 'Example', code: '
large example omitted from view
' }], + }, + { + name: 'WorkerChart', + description: 'Worker-backed chart.', + propsSchema: '{}', + surface: { data: 'worker', authority: 'read' }, + }, + ], +}; + +test('static policy contract view has no tools/components and static runtime', () => { + const view = compileSurfaceContractView({ tier: 'static', purpose: 'inform' }, { + capabilities, + components, + }); + + assert.deepEqual(view.tools, []); + assert.deepEqual(view.components, []); + assert.equal(view.surface.policy.tier, 'static'); + assert.equal(view.surface.plan.runtime, 'static'); + assert.equal(view.surface.mode, 'static'); + assert.equal(view.surface.scriptPolicy, 'forbid'); + assert.deepEqual(view.issues, []); +}); + +test('declarative search policy includes only selected resource state keys', () => { + const view = compileSurfaceContractView({ + tier: 'declarative', + purpose: 'explore', + grants: ['search'], + }, { capabilities }); + + assert.deepEqual(view.tools.map((tool) => tool.name), ['search']); + assert.deepEqual(view.tools[0], { + name: 'search', + kind: 'resource', + description: 'Search host records.', + triggers: ['submit', 'mount'], + argsSchema: '{query: string}', + stateShape: '{loading: boolean, results: SearchResult[], error: string | null, empty: boolean}', + stateKeys: { + loading: 'searchLoading', + data: 'searchResults', + error: 'searchError', + empty: 'searchEmpty', + }, + resultSchema: 'SearchResult[]', + defaultDataShape: '[]', + surface: { data: 'host-resource', authority: 'read' }, + }); + assert.equal(view.surface.plan.runtime, 'declarative'); + assert.equal(view.surface.plan.data, 'host-resource'); + assert.equal(view.surface.plan.authority, 'read'); +}); + +test('component policy includes only selected trusted components without examples', () => { + const view = compileSurfaceContractView({ + tier: 'declarative', + components: ['MetricCard'], + }, { components }); + + assert.deepEqual(view.components, [ + { + name: 'MetricCard', + description: 'Trusted metric card.', + propsSchema: '{label: string, value: string}', + sizing: { width: '320px', height: '112px' }, + surface: { data: 'embedded', authority: 'none' }, + }, + ]); + assert.equal('examples' in view.components[0]!, false); +}); + +test('invalid grants/components preserve compile issues in derived view', () => { + const compiled = compileSurfacePolicy({ + tier: 'declarative', + grants: ['missing', 'analysis'], + components: ['MissingComponent', 'WorkerChart'], + }, { capabilities, components }); + const view = surfaceContractViewFromCompiledPolicy(compiled, { + id: 'host-layout', + slots: [{ id: 'hero', purpose: 'Main result' }], + }); + + assert.deepEqual(view.issues.map((issue) => issue.code), [ + 'surface-policy-unknown-grant', + 'surface-policy-tier-exceeded', + 'surface-policy-unknown-component', + 'surface-policy-tier-exceeded', + ]); + assert.deepEqual(view.layout, { + id: 'host-layout', + slots: [{ id: 'hero', purpose: 'Main result' }], + }); +}); diff --git a/packages/host/src/sandbox-spawner.ts b/packages/host/src/sandbox-spawner.ts index 47d2b68..77e190a 100644 --- a/packages/host/src/sandbox-spawner.ts +++ b/packages/host/src/sandbox-spawner.ts @@ -134,7 +134,12 @@ const SUMMON_BASE_CSS = ` `; interface ResourceMapEntry { - stateKeys: Required>; + stateKeys: { + loading: string; + data: string; + error: string; + empty?: string; + }; } type ResourceMap = Record; @@ -149,6 +154,7 @@ function resourceMapFromCapabilities(capabilities: ValidationCapability[] | unde loading: capability.stateKeys.loading, data: capability.stateKeys.data, error: capability.stateKeys.error, + ...(capability.stateKeys.empty ? { empty: capability.stateKeys.empty } : {}), }, }; } diff --git a/packages/server/src/session.ts b/packages/server/src/session.ts index c01733b..1d5083e 100644 --- a/packages/server/src/session.ts +++ b/packages/server/src/session.ts @@ -1,6 +1,7 @@ import { StreamGraph, compileSurfacePolicy, + surfaceContractViewFromCompiledPolicy, compileSystemContracts, createProtocolHardener, type CompiledSurfacePolicy, @@ -10,6 +11,7 @@ import { type ProtocolHardenerResult, type ProtocolLine, type RepairFeedbackMetaValue, + type SurfaceContractView, } from '@summon-internal/engine'; import { buildEditBlock } from './edit.js'; import { @@ -28,6 +30,7 @@ import type { export class SurfaceGenerationSession { private readonly systemContracts: CompiledSystemContracts; private readonly surfacePolicy: CompiledSurfacePolicy | null; + private readonly surfaceContract: SurfaceContractView | null; private readonly hardener: ProtocolHardener; private readonly acceptedLines: ProtocolLine[] = []; private readonly emittedLines: ProtocolLine[] = []; @@ -54,6 +57,9 @@ export class SurfaceGenerationSession { components: input.components ?? null, }) : null; + this.surfaceContract = this.surfacePolicy + ? surfaceContractViewFromCompiledPolicy(this.surfacePolicy, input.layout ?? null) + : null; this.systemContracts = compileSystemContracts({ mode: this.surfacePolicy?.mode ?? input.mode ?? 'static', direction: input.direction ?? null, @@ -65,6 +71,7 @@ export class SurfaceGenerationSession { components: this.surfacePolicy?.components ?? input.components ?? null, scriptPolicy: this.surfacePolicy?.scriptPolicy ?? input.scriptPolicy, surfacePlan: this.surfacePolicy?.surfacePlan ?? input.surfacePlan ?? null, + surfaceContract: this.surfaceContract, tokenOverrides: input.tokenOverrides, activeTokensCss: input.activeTokensCss ?? null, }); @@ -94,6 +101,9 @@ export class SurfaceGenerationSession { if (surfacePlan) { await this.writeProtocolLine({ op: 'meta', path: '/surface-plan', value: surfacePlan }); } + if (this.surfaceContract) { + await this.writeProtocolLine({ op: 'meta', path: '/surface-contract', value: this.surfaceContract }); + } for (const line of this.systemContracts.startupLines) { this.acceptedLines.push(line); await this.writeProtocolLine(line); diff --git a/packages/server/test/generate-surface-stream.test.ts b/packages/server/test/generate-surface-stream.test.ts index c48f09b..bac2ae0 100644 --- a/packages/server/test/generate-surface-stream.test.ts +++ b/packages/server/test/generate-surface-stream.test.ts @@ -202,9 +202,10 @@ test('runSurfaceGeneration compiles surface policy, emits metadata, and narrows lines.push(line); }); - assert.deepEqual(lines.slice(0, 4).map((line) => `${line.op} ${line.path}`), [ + assert.deepEqual(lines.slice(0, 5).map((line) => `${line.op} ${line.path}`), [ 'meta /surface-policy', 'meta /surface-plan', + 'meta /surface-contract', 'set /screen', 'add /section/hero', ]); @@ -224,8 +225,17 @@ test('runSurfaceGeneration compiles surface policy, emits metadata, and narrows authority: 'host-action', persistence: 'replayable', }); + assert.equal(lines[2]?.op, 'meta'); + const surfaceContract = (lines[2] as Extract).value as { + tools?: Array<{ name: string }>; + components?: Array<{ name: string }>; + surface?: { plan?: unknown }; + }; + assert.deepEqual(surfaceContract.tools?.map((tool) => tool.name), ['choose']); + assert.deepEqual(surfaceContract.components?.map((component) => component.name), ['MetricCard']); assert.match(systemText, /Save a choice/); assert.match(systemText, /MetricCard/); + assert.match(systemText, /Surface contract/); assert.doesNotMatch(systemText, /Search host data/); assert.doesNotMatch(systemText, /SecretWidget/); assert.equal(summary.blocked, false); @@ -262,9 +272,10 @@ test('runSurfaceGeneration blocks invalid surface policy before provider invocat assert.equal(called, false); assert.equal(summary.blocked, true); assert.ok(summary.validationIssues.some((issue) => issue.code === 'surface-policy-tier-exceeded')); - assert.deepEqual(lines.slice(0, 3).map((line) => `${line.op} ${line.path}`), [ + assert.deepEqual(lines.slice(0, 4).map((line) => `${line.op} ${line.path}`), [ 'meta /surface-policy', 'meta /surface-plan', + 'meta /surface-contract', 'meta /validation-blocked', ]); }); diff --git a/packages/summon/src/devtools.ts b/packages/summon/src/devtools.ts index 6b9deaf..edc33c5 100644 --- a/packages/summon/src/devtools.ts +++ b/packages/summon/src/devtools.ts @@ -21,5 +21,6 @@ export type { StatePushedEvent, StreamGraphEvent, StreamLifecycleEvent, + SurfaceContractEvent, SurfacePlanEvent, } from '@summon-internal/devtools'; diff --git a/packages/summon/src/index.ts b/packages/summon/src/index.ts index d1cce02..3386f2a 100644 --- a/packages/summon/src/index.ts +++ b/packages/summon/src/index.ts @@ -14,8 +14,10 @@ export { } from '@summon-internal/host'; export { + compileSurfaceContractView, compileSurfacePolicy, normalizeSurfacePolicy, + surfaceContractViewFromCompiledPolicy, SURFACE_PERSISTENCE_VALUES, SURFACE_PURPOSE_VALUES, SURFACE_TIER_VALUES, @@ -57,10 +59,15 @@ export type { ComponentPack, ComponentSpec, ComponentSurface, + CompileSurfaceContractViewOptions, CompiledSurfacePolicy, CompileSurfacePolicyOptions, IntentSpec, NormalizedSurfacePolicy, + SurfaceContractComponent, + SurfaceContractLayout, + SurfaceContractTool, + SurfaceContractView, SurfacePersistence, SurfacePolicy, SurfacePurpose, diff --git a/scripts/build-public-packages.mjs b/scripts/build-public-packages.mjs index aa15a51..5825a8e 100644 --- a/scripts/build-public-packages.mjs +++ b/scripts/build-public-packages.mjs @@ -23,8 +23,10 @@ const coreExports = { '.': { values: { './_internal/engine/index.js': [ + 'compileSurfaceContractView', 'compileSurfacePolicy', 'normalizeSurfacePolicy', + 'surfaceContractViewFromCompiledPolicy', 'SURFACE_PERSISTENCE_VALUES', 'SURFACE_PURPOSE_VALUES', 'SURFACE_TIER_VALUES', @@ -57,10 +59,15 @@ const coreExports = { 'ComponentPack', 'ComponentSpec', 'ComponentSurface', + 'CompileSurfaceContractViewOptions', 'CompiledSurfacePolicy', 'CompileSurfacePolicyOptions', 'IntentSpec', 'NormalizedSurfacePolicy', + 'SurfaceContractComponent', + 'SurfaceContractLayout', + 'SurfaceContractTool', + 'SurfaceContractView', 'SurfacePersistence', 'SurfacePolicy', 'SurfacePurpose', @@ -120,11 +127,13 @@ const coreExports = { 'buildLayoutBlock', 'buildOverrideBlock', 'buildPosturesBlock', + 'buildSurfaceContractBlock', 'buildSurfacePlanBlock', 'coerceOpts', 'compileCapabilityContract', 'compileComponentContract', 'compileDirectionContract', + 'compileSurfaceContractView', 'compileSurfacePolicy', 'compileSystemContracts', 'compileTokenContract', @@ -148,6 +157,7 @@ const coreExports = { 'parseProtocolLine', 'parseProtocolLineStrict', 'parseTokenValues', + 'surfaceContractViewFromCompiledPolicy', 'surfacePlanScriptPolicy', 'surfacePlanWithinCeiling', 'SURFACE_TIER_VALUES', @@ -176,6 +186,7 @@ const coreExports = { 'CompiledSurfacePolicy', 'CompiledSystemContracts', 'CompiledTokenContract', + 'CompileSurfaceContractViewOptions', 'CompileSurfacePolicyOptions', 'ComponentExample', 'ComponentPack', @@ -224,6 +235,11 @@ const coreExports = { 'SummonLayoutSlot', 'SurfaceAuthority', 'SurfaceCeiling', + 'SurfaceContractComponent', + 'SurfaceContractLayout', + 'SurfaceContractSurface', + 'SurfaceContractTool', + 'SurfaceContractView', 'SurfaceData', 'SurfacePersistence', 'SurfacePolicy', @@ -446,6 +462,7 @@ const coreExports = { 'StatePushedEvent', 'StreamGraphEvent', 'StreamLifecycleEvent', + 'SurfaceContractEvent', 'SurfacePlanEvent', ], }, diff --git a/scripts/check-public-api.mjs b/scripts/check-public-api.mjs index ae2741e..ba9fe45 100644 --- a/scripts/check-public-api.mjs +++ b/scripts/check-public-api.mjs @@ -10,6 +10,7 @@ const expectedRootExports = [ 'SURFACE_PERSISTENCE_VALUES', 'SURFACE_PURPOSE_VALUES', 'SURFACE_TIER_VALUES', + 'compileSurfaceContractView', 'compileSurfacePolicy', 'createCapabilityRegistry', 'createComponentRegistry', @@ -22,6 +23,7 @@ const expectedRootExports = [ 'defineWorkerAction', 'defineWorkerResource', 'normalizeSurfacePolicy', + 'surfaceContractViewFromCompiledPolicy', ].sort(); const expectedServerExports = [ @@ -90,10 +92,13 @@ assertHas('@anarchitecture/summon/browser', await importDist('summon', 'browser. ]); assertHas('@anarchitecture/summon/engine', await importDist('summon', 'engine.js'), [ 'buildCapabilitiesBlock', + 'buildSurfaceContractBlock', + 'compileSurfaceContractView', 'compileSystemContracts', 'createProtocolHardener', 'parseProtocolLine', 'SectionAccumulator', + 'surfaceContractViewFromCompiledPolicy', 'StreamGraph', ]); assertHas('@anarchitecture/summon/host', await importDist('summon', 'host.js'), [ diff --git a/scripts/public-api-manifest.json b/scripts/public-api-manifest.json index 6428d1a..8b264fa 100644 --- a/scripts/public-api-manifest.json +++ b/scripts/public-api-manifest.json @@ -8,6 +8,7 @@ "SURFACE_PERSISTENCE_VALUES", "SURFACE_PURPOSE_VALUES", "SURFACE_TIER_VALUES", + "compileSurfaceContractView", "compileSurfacePolicy", "createCapabilityRegistry", "createComponentRegistry", @@ -19,7 +20,8 @@ "defineIntent", "defineWorkerAction", "defineWorkerResource", - "normalizeSurfacePolicy" + "normalizeSurfacePolicy", + "surfaceContractViewFromCompiledPolicy" ], "types": [ "ActionDefinition", @@ -45,6 +47,7 @@ "ComponentRenderer", "ComponentSpec", "ComponentSurface", + "CompileSurfaceContractViewOptions", "CompiledSurfacePolicy", "CompileSurfacePolicyOptions", "DataResourceDefinition", @@ -55,6 +58,10 @@ "NormalizedSurfacePolicy", "PolicyEngineOptions", "StateShapeDescriptor", + "SurfaceContractComponent", + "SurfaceContractLayout", + "SurfaceContractTool", + "SurfaceContractView", "SurfacePersistence", "SurfacePolicy", "SurfacePurpose", @@ -132,6 +139,7 @@ "StatePushedEvent", "StreamGraphEvent", "StreamLifecycleEvent", + "SurfaceContractEvent", "SurfacePlanEvent" ] }, @@ -167,11 +175,13 @@ "buildLayoutBlock", "buildOverrideBlock", "buildPosturesBlock", + "buildSurfaceContractBlock", "buildSurfacePlanBlock", "coerceOpts", "compileCapabilityContract", "compileComponentContract", "compileDirectionContract", + "compileSurfaceContractView", "compileSurfacePolicy", "compileSystemContracts", "compileTokenContract", @@ -196,6 +206,7 @@ "parseProtocolLineStrict", "parseTokenValues", "surfacePlanScriptPolicy", + "surfaceContractViewFromCompiledPolicy", "surfacePlanWithinCeiling", "validateDirection", "validateHtmlFragment", @@ -220,6 +231,7 @@ "CompiledSurfacePolicy", "CompiledSystemContracts", "CompiledTokenContract", + "CompileSurfaceContractViewOptions", "CompileSurfacePolicyOptions", "ComponentExample", "ComponentPack", @@ -268,6 +280,11 @@ "SummonLayoutSlot", "SurfaceAuthority", "SurfaceCeiling", + "SurfaceContractComponent", + "SurfaceContractLayout", + "SurfaceContractSurface", + "SurfaceContractTool", + "SurfaceContractView", "SurfaceData", "SurfacePersistence", "SurfacePolicy",