Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs/plans/flow-policy-generic-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Feature Plan

## Summary
Flow Policy Generic v1 introduces contract-level flow requirements so Surfaces can evaluate graph-shaped user journeys, not only page/component-level structure. The increment adds additive schema/types/validator support for per-surface flow policies and requirement sets (`flowId`, step constraints, transition constraints, terminal constraints). `interfacectl` consumes explicit per-surface flow descriptor artifacts and enforces the same semantics at `Generation time` and `CI/CD time` with policy-driven severity (`off|warn|strict`).

## Strategy alignment
This work strengthens the decision filter by extending deterministic allow/block behavior to higher-order flow decisions that agents frequently generate. It removes an enforcement gap where flow intent existed only in prompts or documentation and converts it into contract-backed checks with machine-readable findings and lifecycle-consistent handling.

Decision filter sentence
"When an agent proposes a change to the user experience, Surfaces determines whether that change is allowed, under what conditions it may proceed, and blocks or corrects it if it violates intent."

## Contract impact
1. Contract definition
- Add optional `surfaces[].flows` with:
- `policy`: `off|warn|strict`
- `requirements[]`: `{ flowId, minSteps?, requiredSteps?, requiredTransitions?, terminalSteps? }`
- Add flow descriptor shape consumed by validators:
- `flows[]`: `{ flowId, steps[{id}], transitions[{from,to}], source? }`
- No breaking changes to existing contracts; `flows` is optional.

2. Enforcement timing
- `Generation time`: `check-generation-boundaries` evaluates flow policy from descriptor payloads.
- `CI/CD time`: `interfacectl validate` loads flow descriptor artifacts and applies flow checks during compliance evaluation.
- `Runtime (edge)`: out of scope in this repo; handled by consuming surfaces, but compile output carries flow policy for runtime consumers.

3. Violation handling
- `off`: flow checks skipped.
- `warn`: flow findings emitted as warnings, non-blocking.
- `strict`: flow findings emitted as errors, blocking.
- Malformed flow descriptor artifact is treated as `E0` artifact invalid.

## Surface impact
- `interfacectl`: primary implementation surface; schema, validator, CLI ingestion, generation guard, compile output, tests.
- `surfaceops.ai`: indirectly benefits through richer finding taxonomy consumed from run artifacts.
- `surfaces.systems`: indirectly benefits when runtime consumers load compile/runtime manifest flow policy.

## Non-goals
- No scenario-specific scaffolds (checkout/onboarding templates) in this increment.
- No automatic flow extraction from source code or AST-based graph inference.
- No ownership workflow, assignment, or post-hoc observability-only UI work.

## Acceptance criteria
1. Contracts without `flows` remain schema-valid and behaviorally unchanged.
2. Contracts with valid `surfaces[].flows` pass schema validation.
3. Invalid flow schema (for example `minSteps: 0`) fails schema validation with actionable errors.
4. Validator emits all six flow violation types when conditions are met.
5. `validate` maps flow violations to finding codes:
- `descriptor.flows.missing`
- `flow.required.missing`
- `flow.steps.min`
- `flow.steps.required`
- `flow.transition.required`
- `flow.terminal.invalid`
6. `validate` returns warning severity for flow findings under warn policy and error severity under strict policy.
7. Missing flow descriptor artifact produces policy-based flow findings (not `E0`).
8. Malformed flow descriptor artifact fails as `E0`.
9. Generation guard JSON includes `evaluation.flowPolicyEvaluated` and flow findings; text mode remains backward-compatible.
10. Compile output includes `surfaces/<id>.json.flows` when configured.

## Risks and drift checks
1. Risk: drifting into prompt-level guidance instead of enforcement.
- Mitigation: all flow behavior is contract-schema backed and evaluated by validator/guard only.

2. Risk: introducing observability-only signals with no gate behavior.
- Mitigation: preserve policy severity mapping so strict remains blocking in both generation guard and validate.

3. Risk: hidden dependency on extraction or app runtime.
- Mitigation: require explicit flow descriptor artifacts in CI path; no runtime app execution.

## Implementation outline
1. Add `flows` schema definitions and type interfaces.
2. Add validator flow compliance checks and new drift violation taxonomy.
3. Add flow descriptor artifact loading to `validate` and `describe` with fallback path support.
4. Map flow violations to stable finding codes and severity behavior.
5. Extend generation guard JSON flow checks and coverage metadata.
6. Update compile output to include surface flow policy data.
7. Add/extend unit and integration tests across validator, CLI, and guard.
8. Document contract/flow semantics in strategy-adjacent docs.
1 change: 1 addition & 0 deletions packages/interfacectl-cli/dist/commands/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,5 +155,6 @@ function surfaceToBundlePayload(surface) {
allowedFonts: surface.allowedFonts,
layout: surface.layout,
...(surface.icons ? { icons: surface.icons } : {}),
...(surface.flows ? { flows: surface.flows } : {}),
};
}
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/commands/describe.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface DescribeCommandOptions {
}
/**
* Produce descriptor(s) with primitives for pre-emit guard (check-generation-boundaries).
* Output format: array of { surfaceId, primitives, sections, fonts, colors, layout, motion }.
* Output format: array of { surfaceId, primitives, sections, fonts, colors, flows, layout, motion }.
*/
export declare function runDescribeCommand(options: DescribeCommandOptions): Promise<number>;
//# sourceMappingURL=describe.d.ts.map
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/commands/describe.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

165 changes: 163 additions & 2 deletions packages/interfacectl-cli/dist/commands/describe.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,150 @@ async function writeFileWithParents(filePath, contents) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, contents, "utf8");
}
async function loadFlowDescriptorArtifacts({ workspaceRoot, contract, surfaceFilters, flowDescriptorPathMap, }) {
const flowsBySurface = new Map();
const paths = new Map();
for (const surface of contract.surfaces) {
if (!surface.flows || surface.flows.policy === "off") {
continue;
}
if (surfaceFilters.size > 0 && !surfaceFilters.has(surface.id)) {
continue;
}
const configuredPath = flowDescriptorPathMap.get(surface.id) ??
`contracts/generated/${surface.id}.flow-descriptor.json`;
const absolutePath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(workspaceRoot, configuredPath);
const relativePath = path.isAbsolute(configuredPath)
? path.relative(workspaceRoot, configuredPath)
: configuredPath;
paths.set(surface.id, relativePath);
let raw;
try {
raw = await readFile(absolutePath, "utf8");
}
catch (error) {
if (error.code === "ENOENT") {
continue;
}
return {
ok: false,
error: `Failed to read flow descriptor for surface "${surface.id}" at ${absolutePath}: ${error.message}`,
path: absolutePath,
surfaceId: surface.id,
};
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch (error) {
return {
ok: false,
error: `Flow descriptor for surface "${surface.id}" is not valid JSON at ${absolutePath}: ${error.message}`,
path: absolutePath,
surfaceId: surface.id,
};
}
if (!Array.isArray(parsed)) {
return {
ok: false,
error: `Flow descriptor for surface "${surface.id}" must be a JSON array at ${absolutePath}.`,
path: absolutePath,
surfaceId: surface.id,
};
}
const normalizedFlows = [];
for (const [index, entry] of parsed.entries()) {
if (!entry || typeof entry !== "object") {
return {
ok: false,
error: `Flow descriptor entry ${index} for surface "${surface.id}" must be an object at ${absolutePath}.`,
path: absolutePath,
surfaceId: surface.id,
};
}
const entryRecord = entry;
const flowIdValue = entryRecord.flowId;
const flowId = typeof flowIdValue === "string" ? flowIdValue.trim() : "";
if (!flowId) {
return {
ok: false,
error: `Flow descriptor entry ${index} for surface "${surface.id}" is missing a non-empty flowId at ${absolutePath}.`,
path: absolutePath,
surfaceId: surface.id,
};
}
const stepsRaw = entryRecord.steps;
if (!Array.isArray(stepsRaw)) {
return {
ok: false,
error: `Flow descriptor "${flowId}" for surface "${surface.id}" must include steps[] at ${absolutePath}.`,
path: absolutePath,
surfaceId: surface.id,
};
}
const steps = [];
for (const [stepIndex, step] of stepsRaw.entries()) {
const stepRecord = step && typeof step === "object"
? step
: undefined;
const stepIdValue = stepRecord?.id;
const stepId = typeof stepIdValue === "string" ? stepIdValue.trim() : "";
if (!stepId) {
return {
ok: false,
error: `Flow descriptor "${flowId}" step ${stepIndex} for surface "${surface.id}" must include non-empty id at ${absolutePath}.`,
path: absolutePath,
surfaceId: surface.id,
};
}
steps.push({ id: stepId });
}
const transitionsRaw = entryRecord.transitions;
if (!Array.isArray(transitionsRaw)) {
return {
ok: false,
error: `Flow descriptor "${flowId}" for surface "${surface.id}" must include transitions[] at ${absolutePath}.`,
path: absolutePath,
surfaceId: surface.id,
};
}
const transitions = [];
for (const [transitionIndex, transition] of transitionsRaw.entries()) {
const transitionRecord = transition && typeof transition === "object"
? transition
: undefined;
const fromValue = transitionRecord?.from;
const toValue = transitionRecord?.to;
const from = typeof fromValue === "string" ? fromValue.trim() : "";
const to = typeof toValue === "string" ? toValue.trim() : "";
if (!from || !to) {
return {
ok: false,
error: `Flow descriptor "${flowId}" transition ${transitionIndex} for surface "${surface.id}" must include non-empty from/to at ${absolutePath}.`,
path: absolutePath,
surfaceId: surface.id,
};
}
transitions.push({ from, to });
}
const sourceValue = entryRecord.source;
normalizedFlows.push({
flowId,
steps,
transitions,
source: typeof sourceValue === "string" ? sourceValue : relativePath,
});
}
flowsBySurface.set(surface.id, normalizedFlows);
}
return { ok: true, flowsBySurface, paths };
}
/**
* Produce descriptor(s) with primitives for pre-emit guard (check-generation-boundaries).
* Output format: array of { surfaceId, primitives, sections, fonts, colors, layout, motion }.
* Output format: array of { surfaceId, primitives, sections, fonts, colors, flows, layout, motion }.
*/
export async function runDescribeCommand(options) {
const cwd = process.cwd();
Expand Down Expand Up @@ -92,11 +233,17 @@ export async function runDescribeCommand(options) {
const contract = structureResult.contract;
const configResult = await loadConfigFile(configPath);
const surfaceRootMap = new Map();
const flowDescriptorPathMap = new Map();
if (configResult.ok && configResult.config?.surfaceRoots) {
for (const [surfaceId, surfaceRoot] of Object.entries(configResult.config.surfaceRoots)) {
surfaceRootMap.set(surfaceId, surfaceRoot);
}
}
if (configResult.ok && configResult.config?.flowDescriptorPaths) {
for (const [surfaceId, flowDescriptorPath] of Object.entries(configResult.config.flowDescriptorPaths)) {
flowDescriptorPathMap.set(surfaceId, flowDescriptorPath);
}
}
const surfaceFilters = new Set((options.surface ?? []).map((s) => s.trim()).filter(Boolean));
const descriptorResult = await collectSurfaceDescriptors({
workspaceRoot,
Expand All @@ -111,7 +258,21 @@ export async function runDescribeCommand(options) {
}
return 1;
}
const descriptors = descriptorResult.descriptors;
const flowDescriptorResult = await loadFlowDescriptorArtifacts({
workspaceRoot,
contract,
surfaceFilters,
flowDescriptorPathMap,
});
if (!flowDescriptorResult.ok) {
console.error(`Error: ${flowDescriptorResult.error}`);
return 1;
}
const descriptors = descriptorResult.descriptors.map((descriptor) => ({
...descriptor,
flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId),
flowDescriptorPath: flowDescriptorResult.paths.get(descriptor.surfaceId),
}));
const serialized = `${JSON.stringify(descriptors, null, 2)}\n`;
await writeFileWithParents(outPath, serialized);
return 0;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions packages/interfacectl-cli/dist/commands/generate-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { writeFile, mkdir, readFile } from "node:fs/promises";
import { extractContractFromNextApp, stableStringify, } from "@surfaces/interfacectl-extractor";
import { getBundledContractSchema, validateContractStructure, } from "@surfaces/interfacectl-validator";
import { seedColorPolicyFromObservedDescriptors } from "../utils/color-policy-seeding.js";
import { seedChromePolicyDefaults } from "../utils/chrome-policy-seeding.js";
import { seedIconPolicyFromObservedDescriptors } from "../utils/icon-policy-seeding.js";
const DEFAULT_OUT_DIR = "contracts/generated";
export async function runGenerateContractCommand(options) {
Expand Down Expand Up @@ -34,10 +35,18 @@ export async function runGenerateContractCommand(options) {
surfaceId,
contract: seeded.contract,
});
const contract = iconSeeded.contract;
const chromeSeeded = await seedChromePolicyDefaults({
contract: iconSeeded.contract,
});
const contract = chromeSeeded.contract;
const reportWithSeedWarnings = {
...report,
warnings: [...report.warnings, ...seeded.warnings, ...iconSeeded.warnings],
warnings: [
...report.warnings,
...seeded.warnings,
...iconSeeded.warnings,
...chromeSeeded.warnings,
],
};
let schema;
if (options.schemaPath) {
Expand Down
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/commands/validate.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading