Profiles deny surfaces by manifest id: fences, generalized teachings, and the determinism invariant

- A fences array denies manifest-identified surfaces reached by the compiled library graph: SC4008 names the id and surface with the profile's teaching as an attributed note; unreached fences leave emission byte-identical
- Fence evaluation walks the dead-stripped module IR — the same ground as the deterministic attestation, making the full-fences invariant structural
- Teachings generalize to any refusal code or manifest id; unknown or unpoliceable fence selectors refuse loudly rather than sit inert
- Teaching and remediation strings take the ratified byte constraints and the 512-byte cap everywhere
This commit is contained in:
Chris Tate
2026-07-24 09:18:32 -05:00
10 changed files with 1076 additions and 61 deletions
+51 -19
View File
@@ -29,8 +29,9 @@
* LIB_RUNTIME_TRAP_CODES below), and npm packages a library
* graph imports that fail the npm-static eligibility bar
* (SC4020 — static-or-refuse; eligible packages compile
* statically instead); SC4008 reserved for ask-5
* determinism denials
* statically instead), and the ask-5 determinism fence
* denial (SC4008 — a profile-fenced manifest surface reached
* by the compiled library module graph)
* SC9xxx internal compiler errors (still source-anchored)
*/
import type { SrcLoc } from "../ir/nodes.js";
@@ -46,6 +47,12 @@ export interface ScrDiagnostic {
loc: SrcLoc;
milestone?: Milestone;
hint?: string;
/** Embedder-authored guidance riding a refusal (the ask-5 teaching
* surface): rendered as its own visibly-attributed trailing line, so the
* tool's message and hint stay the tool's and the profile's text is
* recognizably the profile's. Only library-mode refusals carry one —
* the text always arrives prefixed `from the '<profile name>' profile:`. */
note?: string;
}
interface UnsupportedEntry {
@@ -650,9 +657,12 @@ export function comptimeFailedDiag(detail: string, loc: SrcLoc): ScrDiagnostic {
/* ── SC4xxx: library-mode/profile refusals ─────────────────────────────────
* The library-emission mode's own code block (design §5.4 + the ratified
* SC4xxx range): neither preflight nor type rules nor backend coverage —
* each refusal names the profile entry involved and the fix, and SC4004/
* SC4005 additionally carry the profile's own teaching text when the
* profile supplies one (the ratification session's rider). */
* each refusal names the profile entry involved and the fix. Profile
* teaching text no longer threads through individual factories: EVERY
* library-mode refusal passes through one decoration pass
* (library/fence-eval.ts's decorateLibraryRefusals — the SC4004/SC4005
* rider generalized to any refusal code or manifest id), which attaches
* the profile's text as the diagnostic's attributed `note`. */
/** SC4001 — the profile file itself is malformed: parse errors, unknown
* fields at meaning-changing positions, prefix violations, duplicate
@@ -698,39 +708,63 @@ export function libUnmappableSignatureDiag(
}
/** SC4004 — a mapped export is async or a generator: a synchronous ABI
* entry cannot be either. Carries the profile's teaching when supplied. */
* entry cannot be either. The profile's teaching arrives via the shared
* decoration pass (the "async" shared key or an SC4004 key). */
export function libAsyncExportDiag(
exportName: string,
kind: "async" | "generator",
loc: SrcLoc,
teaching?: string,
): ScrDiagnostic {
return {
code: "SC4004",
message: `library export '${exportName}' is ${kind === "async" ? "an async function" : "a generator"} — a profile entry is a synchronous ABI symbol`,
loc,
hint:
"expose a synchronous facade function and map that instead" +
(teaching !== undefined ? ` — ${teaching}` : ""),
hint: "expose a synchronous facade function and map that instead",
};
}
/** SC4005 — the library module graph reaches event-loop or ambient-process
* surface (async functions, generators, timers, sockets, signals, child
* processes, ...): async_free is a v1 REQUIREMENT, derived from the module
* graph, never observed at runtime. Carries the profile's teaching. */
export function libAsyncSurfaceDiag(surface: string, loc: SrcLoc, teaching?: string): ScrDiagnostic {
* graph, never observed at runtime. */
export function libAsyncSurfaceDiag(surface: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC4005",
message: `library mode requires an async_free module graph, and this graph reaches ${surface}`,
loc,
hint:
"v1 library artifacts link no event loop, install no signal handlers, and create no threads — remove the surface from " +
"everything the entry module reaches" +
(teaching !== undefined ? ` — ${teaching}` : ""),
"everything the entry module reaches",
};
}
/** SC4008 — the ask-5 determinism denial: the profile fences a surface by
* its surface-manifest id, and the compiled library module graph REACHES
* it. Minted only for manifest entries the static tier compiles (a fenced
* surface the tier refuses anyway keeps its own code, with the fence's
* teaching riding that refusal as the note); anchored at the reaching
* construct. `surfaceCode` is the diagnostic code the manifest entry
* carries, when it carries one. */
export function libFenceDiag(
profileName: string,
surfaceId: string,
surfaceName: string,
loc: SrcLoc,
surfaceCode?: string,
teaching?: string,
): ScrDiagnostic {
const diag: ScrDiagnostic = {
code: "SC4008",
message: `the '${profileName}' profile fences '${surfaceId}' (${surfaceName}${surfaceCode !== undefined ? `, ${surfaceCode}` : ""}), and this library graph reaches it`,
loc,
hint:
"the profile denies this surface at compile time (a determinism fence over the surface manifest's ids) — " +
"remove the use from everything the entry module reaches, or lift the fence",
};
if (teaching !== undefined) diag.note = `from the '${profileName}' profile: ${teaching}`;
return diag;
}
/** SC4006 — island/dynamic machinery on the library path: --dynamic,
* --npm-static as a FLAG, `any`-typed island surface. Bare npm imports
* themselves are not this code: an eligible package compiles statically
@@ -859,16 +893,14 @@ export const LIB_RUNTIME_TRAP_CODES = [
* had to refuse. Library mode is STATIC-OR-REFUSE by construction: the
* island/dynamic tier the executable lane falls back to does not exist on
* this path (SC4006's ground), so the refusal names the package, the
* specific bar it missed, and the remedy. Carries the profile's teaching
* for this code when supplied. */
export function libNpmIneligibleDiag(pkg: string, reason: string, loc: SrcLoc, teaching?: string): ScrDiagnostic {
* specific bar it missed, and the remedy. */
export function libNpmIneligibleDiag(pkg: string, reason: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC4020",
message: `library mode compiles npm packages statically or not at all, and '${pkg}' cannot compile statically: ${reason}`,
loc,
hint:
"library artifacts have no island/dynamic tier to fall back to — vendor the code you need from the package as project modules, or drop the dependency" +
(teaching !== undefined ? ` — ${teaching}` : ""),
"library artifacts have no island/dynamic tier to fall back to — vendor the code you need from the package as project modules, or drop the dependency",
};
}
+9 -1
View File
@@ -1,5 +1,6 @@
/* Terminal rendering: file:line:col, a hand-rolled code frame with ±1 line
* of context and a ^~~~ underline sized to the span, optional hint line.
* of context and a ^~~~ underline sized to the span, optional hint line,
* and the optional attributed profile-note line after it.
*/
import type { ScrDiagnostic } from "./diagnostic.js";
@@ -71,6 +72,13 @@ export function renderDiagnostic(
if (diag.hint) {
out.push("", ` ${c(CYAN, "hint:")} ${diag.hint}`);
}
if (diag.note) {
// Embedder-authored teaching text: its own attributed trailing line
// (the text itself begins "from the '<profile>' profile:"), so the
// tool's message and hint stay visibly the tool's.
if (!diag.hint) out.push("");
out.push(` ${c(CYAN, "note:")} ${diag.note}`);
}
return out.join("\n");
}
+18 -10
View File
@@ -5,6 +5,7 @@ import { emitModule } from "./backend/emission/emitter.js";
import { emitLlvmModule, LlvmUnsupportedError } from "./backend/llvm/emitter.js";
import { checkerPanicDiag, libAsyncExportDiag, libAsyncSurfaceDiag, libExportUnresolvedDiag, libGenericExportDiag, libNpmIneligibleDiag, libUnmappableSignatureDiag, iceDiag, isCheckerPanic, LIB_INBOUND_BYTES_TRAP_CODE, LIB_RUNTIME_TRAP_CODES, type ScrDiagnostic } from "./diagnostics/diagnostic.js";
import { loadLibraryProfile, profileRemediation, profileTeaching, type LibraryProfile } from "./library/profile.js";
import { decorateLibraryRefusals, evaluateLibraryFences } from "./library/fence-eval.js";
import { assembleTrapTeaching } from "./library/trap-teaching.js";
import {
buildSidecar,
@@ -805,9 +806,7 @@ function resolveLibrarySection(
continue;
}
if (info.async || info.generator) {
diagnostics.push(
libAsyncExportDiag(e.export, info.async ? "async" : "generator", info.loc, profileTeaching(profile, "SC4004")),
);
diagnostics.push(libAsyncExportDiag(e.export, info.async ? "async" : "generator", info.loc));
continue;
}
const fn = fnByName.get(e.export);
@@ -934,9 +933,12 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
let entryInfo: Map<string, EntryExportInfo>;
let contractFacts: ContractFacts | null;
try {
// Every library refusal leaves through the ask-5 teaching decoration:
// profile text attaches by code, manifest id, or fence coverage as the
// attributed note (the SC4004/SC4005 rider generalized).
const fail = (diagnostics: ScrDiagnostic[]): CompileLibraryResult => ({
ok: false,
diagnostics,
diagnostics: decorateLibraryRefusals(diagnostics, profile),
sourceTexts: fe.sourceTexts(),
});
// The npm verdicts FIRST: whatever the shared frontend would have
@@ -957,7 +959,6 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
// this path to serve anything.
(s.detail ?? "its static compilation was refused").replace("; the island serves the package", ""),
fe.npmImportSites.get(s.package) ?? { file: entryPath, start: 0, end: 0 },
profileTeaching(profile, "SC4020"),
),
),
);
@@ -986,19 +987,26 @@ export async function compileLibrary(opts: CompileLibraryOptions): Promise<Compi
}
const mod = lowered.module!;
const fail = (diagnostics: ScrDiagnostic[]): CompileLibraryResult => ({ ok: false, diagnostics, sourceTexts });
const fail = (diagnostics: ScrDiagnostic[]): CompileLibraryResult => ({
ok: false,
diagnostics: decorateLibraryRefusals(diagnostics, profile),
sourceTexts,
});
// Export resolution first (SC4002/SC4003/SC4004/SC4007 anchor at the
// mapped declaration — a mapped async export reports as SC4004, not the
// graph-wide gate), then the async_free requirement (ratified, SC4005):
// both refused before anything is emitted, so the narrowed library link set
// below is structural fact.
// graph-wide gate), then the async_free requirement (ratified, SC4005),
// then the profile's determinism fences (ask 5, SC4008) over the same
// compiled graph the attestation scan reads: all refused before anything
// is emitted, so the narrowed library link set below is structural fact.
const resolved = resolveLibrarySection(profile, entryInfo, mod, entryPath);
if ("diagnostics" in resolved) return fail(resolved.diagnostics);
const asyncSurface = moduleLibAsyncSurface(mod);
if (asyncSurface !== null) {
return fail([libAsyncSurfaceDiag(asyncSurface.surface, asyncSurface.loc, profileTeaching(profile, "SC4005"))]);
return fail([libAsyncSurfaceDiag(asyncSurface.surface, asyncSurface.loc)]);
}
const fenced = evaluateLibraryFences(mod, profile);
if (fenced.length > 0) return fail(fenced);
mod.lib = resolved.lib;
// The ask-2 contract sidecar rides the same invocation. Identity first
+463
View File
@@ -0,0 +1,463 @@
/* Ask-5 determinism fences + the generalized teaching decoration.
*
* The profile's `determinism.fences` array denies surfaces BY SURFACE-
* MANIFEST ID (coverage/surface-manifest.ts — the compiler's one stable
* surface taxonomy; no new vocabulary). Resolution happens at profile
* load, against the manifest generated from this compiler's own tables:
*
* - an `id` names exactly one manifest entry; a `prefix` fences every
* entry it string-prefixes. An id or prefix matching NOTHING refuses
* loudly at load (ratified): fences pin to a compiler release's
* manifest, and a silently-inert fence is a footgun, so there is no
* forward-compatible acceptance of unknown selectors.
* - a fenced entry the static tier refuses anyway (status not "static")
* needs no new machinery: the surface's own refusal fires, and the
* fence's teaching rides it as the attributed note (spec §1: "changes
* only the message").
* - a fenced STATIC entry is policed against the COMPILED module graph:
* the emit pass lowers only reachability-discovered bodies, so the
* module IR *is* the reached graph — the same ground the sidecar's
* `deterministic` attestation stands on (ir/nodes.ts's
* moduleLibNondeterministicSurface). Fence evaluation reuses that
* posture with one generic walk collecting the reached surface facts
* (libCall spellings, str/arr/map/set intrinsic methods); each fenced
* entry then answers by table-derived detector. Unreached fences cost
* exactly this one walk — and no walk at all when no fence declares a
* detector-backed surface.
* - a fenced static entry with NO honest detector refuses at load,
* never accepted-and-ignored. Two families are named specifically:
* compile-time-folded constants (os.EOL, path.sep — the artifact
* performs no runtime read a fence could deny) and desugared surfaces
* (array map/filter/forEach and friends lower to plain loops with no
* distinct trace).
*
* Detectors derive MECHANICALLY from the same lowering tables the
* manifest projects from (frontend/lowering/surfaces.ts), so a table row
* added later grows the fenceable surface without hand maintenance. Two
* aliased-lowering facts are inherited from the tables and deliberate:
* string trimLeft/trimRight share trimStart/trimEnd's IR method, and
* querystring decode/encode share parse/stringify's libCall — fencing one
* spelling fences the operation.
*
* decorateLibraryRefusals is the generalized teaching rider (spec §2):
* profile text attaches to ANY library-mode refusal by diagnostic code
* (the ratified SC4004/SC4005 rider's keys, "async" shared-key fallback
* included), by manifest id (matched against the refusal's code plus the
* entry's surface name in the message; diagnostic-fence entries match on
* code alone — the entry IS the code family), or by a fence entry
* covering the refused surface. The text renders as the diagnostic's
* `note`, always prefixed with the profile's name — the tool's message
* stays the tool's. */
import { generateSurfaceManifest, type SurfaceEntryKind, type SurfaceManifestEntry } from "../coverage/surface-manifest.js";
import { libFenceDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js";
import {
BUILTIN_MODULE_CONSTS,
BUILTIN_MODULE_FNS,
STATIC_MATH_FNS,
STR_METHODS,
} from "../frontend/lowering/surfaces.js";
import type { IrModule, SrcLoc } from "../ir/nodes.js";
import { compilerReleaseVersion } from "./sidecar.js";
/** One raw fence declaration as the profile spelled it (validated for
* shape by the loader; resolved here against the manifest). */
export interface LibraryFenceDecl {
/** JSON path for exact-path refusals: `determinism.fences[2]`. */
path: string;
id?: string;
prefix?: string;
teaching?: string;
remediation?: string;
}
/** The IR facts that witness one surface's reach in the compiled graph.
* A surface is reached exactly when any listed fact appears. */
interface SurfaceDetector {
libFns?: readonly string[];
strMethods?: readonly string[];
arrMethods?: readonly string[];
mapMethods?: readonly string[];
setMethods?: readonly string[];
}
/** One manifest entry a fence covers, resolved and load-validated. */
export interface FencedSurface {
id: string;
name: string;
kind: SurfaceEntryKind;
/** The diagnostic code the manifest entry carries (every non-static
* entry has one) — the code whose refusal the fence teaching rides. */
code?: string;
/** Present exactly when the entry is static (it compiles, so the fence
* must deny it): the reach witness. Absent = the surface already
* refuses with `code` and the fence changes only the message. */
detector?: SurfaceDetector;
}
export interface ResolvedLibraryFence {
/** The declared selector, for messages: `id 'stdlib.math.random'` or
* `prefix 'node-builtin.fs.'`. */
declared: string;
teaching?: string;
remediation?: string;
surfaces: readonly FencedSurface[];
}
/* ── the taxonomy: this release's manifest, indexed once ──────────────── */
interface FenceTaxonomy {
byId: Map<string, SurfaceManifestEntry>;
ids: readonly string[];
/** node-builtin member id → libCall fn spellings (platform twins
* included: a fence covers the surface on every target). */
builtinFns: Map<string, readonly string[]>;
/** node-builtin module-root id → the union of its members' spellings. */
builtinRoots: Map<string, readonly string[]>;
/** node-builtin constant ids: reads fold to literals at compile time. */
foldedIds: Set<string>;
}
let taxonomy: FenceTaxonomy | null = null;
function builtinRootId(mod: string): string {
return `node-builtin.${mod.split("/").join(".")}`;
}
function fenceTaxonomy(): FenceTaxonomy {
if (taxonomy !== null) return taxonomy;
const manifest = generateSurfaceManifest(compilerReleaseVersion());
const byId = new Map(manifest.entries.map((e) => [e.id, e]));
const builtinFns = new Map<string, readonly string[]>();
const builtinRoots = new Map<string, readonly string[]>();
const foldedIds = new Set<string>();
for (const [mod, members] of Object.entries(BUILTIN_MODULE_FNS)) {
const root = new Set<string>();
for (const [member, entry] of Object.entries(members!)) {
if (entry === undefined) continue;
const fns = new Set([entry.fn]);
// The bare-path and url tables rebind per TARGET platform
// (builtinModuleFnsOf): a fence on the bare id covers both flavors.
if (mod === "path") {
const w = BUILTIN_MODULE_FNS["path/win32"]?.[member];
if (w !== undefined) fns.add(w.fn);
}
if (mod === "url" && member === "pathToFileURL") fns.add("url.pathToFileURLWin32");
builtinFns.set(`${builtinRootId(mod)}.${member}`, [...fns]);
for (const f of fns) root.add(f);
}
if (root.size > 0) builtinRoots.set(builtinRootId(mod), [...root]);
}
for (const [mod, members] of Object.entries(BUILTIN_MODULE_CONSTS)) {
for (const member of Object.keys(members!)) foldedIds.add(`${builtinRootId(mod)}.${member}`);
}
taxonomy = { byId, ids: manifest.entries.map((e) => e.id), builtinFns, builtinRoots, foldedIds };
return taxonomy;
}
/* ── classification: what policing a static entry means ───────────────── */
/** Array methods with a distinct IR trace (arrIntrinsic methods). `push`
* includes the spread spelling — `a.push(...xs)` lowers to pushSpread and
* is the same surface. The rest of ARRAY_METHODS desugars to plain loops. */
const ARR_DETECTABLE: Record<string, readonly string[] | undefined> = {
push: ["push", "pushSpread"],
pop: ["pop"],
indexOf: ["indexOf"],
includes: ["includes"],
join: ["join"],
slice: ["slice"],
};
const MAP_DETECTABLE = new Set(["get", "set", "has", "delete", "clear"]);
const SET_DETECTABLE = new Set(["add", "has", "delete", "clear"]);
type SurfaceClass =
| { kind: "detector"; detector: SurfaceDetector }
| { kind: "refusal" }
| { kind: "unfenceable"; why: string };
const DESUGARED_WHY =
"it desugars at compile time into plain loops over iteration primitives, so the compiled graph carries no distinct trace to police";
function classifySurface(entry: SurfaceManifestEntry, tax: FenceTaxonomy): SurfaceClass {
// Non-static entries already refuse with their own code wherever they
// are reached — the fence's teaching rides that refusal.
if (entry.status !== "static") return { kind: "refusal" };
if (tax.foldedIds.has(entry.id)) {
return {
kind: "unfenceable",
why: "reads of it fold to a per-binary constant at compile time — the artifact performs no runtime read a fence could deny",
};
}
const builtinFns = tax.builtinFns.get(entry.id);
if (builtinFns !== undefined) return { kind: "detector", detector: { libFns: builtinFns } };
const rootFns = tax.builtinRoots.get(entry.id);
if (rootFns !== undefined) return { kind: "detector", detector: { libFns: rootFns } };
const [, family, member] = entry.id.split(".");
if (entry.id.startsWith("stdlib.") && family !== undefined && member !== undefined) {
if (family === "math") {
const fn = Object.hasOwn(STATIC_MATH_FNS, member) ? STATIC_MATH_FNS[member] : undefined;
if (fn !== undefined) return { kind: "detector", detector: { libFns: [fn.fn] } };
}
if (family === "string") {
const m = Object.hasOwn(STR_METHODS, member) ? STR_METHODS[member] : undefined;
if (m !== undefined) return { kind: "detector", detector: { strMethods: [m.method] } };
}
if (family === "array") {
const methods = Object.hasOwn(ARR_DETECTABLE, member) ? ARR_DETECTABLE[member] : undefined;
if (methods !== undefined) return { kind: "detector", detector: { arrMethods: methods } };
return { kind: "unfenceable", why: DESUGARED_WHY };
}
if (family === "map") {
if (MAP_DETECTABLE.has(member)) return { kind: "detector", detector: { mapMethods: [member] } };
return { kind: "unfenceable", why: DESUGARED_WHY };
}
if (family === "set") {
if (SET_DETECTABLE.has(member)) return { kind: "detector", detector: { setMethods: [member] } };
return { kind: "unfenceable", why: DESUGARED_WHY };
}
}
if (entry.id.startsWith("syntax.")) {
return {
kind: "unfenceable",
why: "it lowers to the same IR as its expanded spelling, so the compiled graph carries no distinct trace to police",
};
}
if (entry.id.startsWith("node-builtin.")) {
return {
kind: "unfenceable",
why: "its lowered surface leaves no tabled per-call trace at this compiler release — fence the member entries the manifest projects instead",
};
}
return { kind: "unfenceable", why: "no fence detector exists for it at this compiler release" };
}
/* ── load-time resolution ──────────────────────────────────────────────── */
/** Resolve the profile's fence declarations against this compiler
* release's surface manifest. Every failure returns the exact-path detail
* the loader wraps as SC4001. Selector strictness is RATIFIED: an id or
* prefix matching nothing refuses; a covered static entry no detector can
* police refuses (loudly, never silently inert). Compile-time-folded
* constants refuse as `id` fences (the message says why) and are exempt
* under a `prefix` — the family sweep stays usable, and the fold means
* there is genuinely nothing at runtime to deny. */
export function resolveLibraryFences(
decls: readonly LibraryFenceDecl[],
): { ok: true; fences: ResolvedLibraryFence[] } | { ok: false; detail: string } {
const tax = fenceTaxonomy();
const fences: ResolvedLibraryFence[] = [];
for (const d of decls) {
const surfaces: FencedSurface[] = [];
const keep = (entry: SurfaceManifestEntry, cls: SurfaceClass): void => {
surfaces.push({
id: entry.id,
name: entry.name,
kind: entry.kind,
...(entry.code !== undefined ? { code: entry.code } : {}),
...(cls.kind === "detector" ? { detector: cls.detector } : {}),
});
};
if (d.id !== undefined) {
const entry = tax.byId.get(d.id);
if (entry === undefined) {
return {
ok: false,
detail:
`'${d.path}.id' names no entry in this compiler release's surface manifest ('${d.id}') — ` +
`fences pin to the release's manifest ids (the package ships surface-manifest.json), and an unknown id is refused, never accepted as inert`,
};
}
const cls = classifySurface(entry, tax);
if (cls.kind === "unfenceable") {
return { ok: false, detail: `'${d.path}.id' names '${d.id}', which cannot be fenced: ${cls.why}` };
}
keep(entry, cls);
} else {
const covered = tax.ids.filter((id) => id.startsWith(d.prefix!));
if (covered.length === 0) {
return {
ok: false,
detail:
`'${d.path}.prefix' matches no entry in this compiler release's surface manifest ('${d.prefix}') — ` +
`fences pin to the release's manifest ids (the package ships surface-manifest.json), and an unknown prefix is refused, never accepted as inert`,
};
}
for (const id of covered) {
const entry = tax.byId.get(id)!;
const cls = classifySurface(entry, tax);
if (cls.kind === "unfenceable") {
if (tax.foldedIds.has(id)) continue; // the fold IS the exemption — see above
return { ok: false, detail: `'${d.path}.prefix' ('${d.prefix}') covers '${id}', which cannot be fenced: ${cls.why}` };
}
keep(entry, cls);
}
if (surfaces.length === 0) {
return {
ok: false,
detail: `'${d.path}.prefix' ('${d.prefix}') covers only compile-time-folded constants — there is nothing at runtime for the fence to deny`,
};
}
}
fences.push({
declared: d.id !== undefined ? `id '${d.id}'` : `prefix '${d.prefix}'`,
...(d.teaching !== undefined ? { teaching: d.teaching } : {}),
...(d.remediation !== undefined ? { remediation: d.remediation } : {}),
surfaces,
});
}
return { ok: true, fences };
}
/* ── compile-time evaluation over the reached graph ────────────────────── */
interface ReachedSurfaces {
libFns: Map<string, SrcLoc>;
strMethods: Map<string, SrcLoc>;
arrMethods: Map<string, SrcLoc>;
mapMethods: Map<string, SrcLoc>;
setMethods: Map<string, SrcLoc>;
}
/** One generic walk over the compiled module (the moduleLibAsyncSurface
* shape: nearest enclosing `loc` tracked so refusals anchor at the
* reaching construct), collecting every reached surface fact the fence
* detectors speak. First location per fact wins. */
function collectReachedSurfaces(mod: IrModule): ReachedSurfaces {
const reached: ReachedSurfaces = {
libFns: new Map(),
strMethods: new Map(),
arrMethods: new Map(),
mapMethods: new Map(),
setMethods: new Map(),
};
const note = (map: Map<string, SrcLoc>, key: string, loc: SrcLoc): void => {
if (!map.has(key)) map.set(key, loc);
};
const entryLoc: SrcLoc = { file: mod.sourceFile, start: 0, end: 0 };
const visit = (v: unknown, loc: SrcLoc): void => {
if (v === null || typeof v !== "object") return;
if (Array.isArray(v)) {
for (const item of v) visit(item, loc);
return;
}
const node = v as { kind?: unknown; fn?: unknown; method?: unknown; loc?: SrcLoc };
const here = node.loc ?? loc;
if (typeof node.kind === "string") {
if (node.kind === "libCall" && typeof node.fn === "string") note(reached.libFns, node.fn, here);
else if (typeof node.method === "string") {
if (node.kind === "strIntrinsic") note(reached.strMethods, node.method, here);
else if (node.kind === "arrIntrinsic") note(reached.arrMethods, node.method, here);
else if (node.kind === "mapIntrinsic") note(reached.mapMethods, node.method, here);
else if (node.kind === "setIntrinsic") note(reached.setMethods, node.method, here);
}
}
for (const key of Object.keys(v)) visit((v as Record<string, unknown>)[key], here);
};
visit(mod, entryLoc);
return reached;
}
function detectorHit(reached: ReachedSurfaces, det: SurfaceDetector): SrcLoc | null {
const scan = (facts: Map<string, SrcLoc>, keys: readonly string[] | undefined): SrcLoc | null => {
for (const k of keys ?? []) {
const loc = facts.get(k);
if (loc !== undefined) return loc;
}
return null;
};
return (
scan(reached.libFns, det.libFns) ??
scan(reached.strMethods, det.strMethods) ??
scan(reached.arrMethods, det.arrMethods) ??
scan(reached.mapMethods, det.mapMethods) ??
scan(reached.setMethods, det.setMethods)
);
}
/** The structural slice of LibraryProfile the fence machinery reads —
* kept structural so this module never value-imports the loader. */
export interface FenceProfileView {
name: string;
teachings: Readonly<Record<string, string>>;
fences: readonly ResolvedLibraryFence[];
}
/** Evaluate the profile's fences against the COMPILED module graph: every
* fenced static surface the graph reaches refuses as SC4008, anchored at
* the reaching construct, the fence's teaching attached as the attributed
* note. Unreached fences add nothing; a profile whose fences carry no
* detector-backed surface skips the walk entirely. */
export function evaluateLibraryFences(mod: IrModule, profile: FenceProfileView): ScrDiagnostic[] {
if (!profile.fences.some((f) => f.surfaces.some((s) => s.detector !== undefined))) return [];
const reached = collectReachedSurfaces(mod);
const diags: ScrDiagnostic[] = [];
const seen = new Set<string>();
for (const fence of profile.fences) {
for (const s of fence.surfaces) {
if (s.detector === undefined || seen.has(s.id)) continue;
const loc = detectorHit(reached, s.detector);
if (loc === null) continue;
seen.add(s.id);
diags.push(libFenceDiag(profile.name, s.id, s.name, loc, s.code, fence.teaching));
}
}
return diags;
}
/* ── the generalized teaching rider ────────────────────────────────────── */
function surfaceMatchesDiag(
surface: { code?: string; name: string; kind: SurfaceEntryKind },
diag: ScrDiagnostic,
): boolean {
if (surface.code === undefined || surface.code !== diag.code) return false;
// A diagnostic-fence entry IS the code family; member entries must also
// appear by name in the refusal's message (SC2020/SC2012 refusals spell
// the surface exactly as the manifest names it).
return surface.kind === "diagnostic-fence" || diag.message.includes(surface.name);
}
function teachingForRefusal(profile: FenceProfileView, diag: ScrDiagnostic): string | undefined {
// 1. Code-keyed teachings — the ratified SC4004/SC4005 rider's keys,
// "async" shared-key fallback included (profile.ts's profileTeaching
// rule, restated here to keep the import graph acyclic).
const direct =
profile.teachings[diag.code] ??
(diag.code === "SC4004" || diag.code === "SC4005" ? profile.teachings["async"] : undefined);
if (direct !== undefined) return direct;
// 2. Manifest-id-keyed teachings (dotted keys — embedder refusal-code
// tokens never carry dots; an id key resolves against the taxonomy and
// attaches where that surface's refusal fired).
for (const [key, text] of Object.entries(profile.teachings)) {
if (!key.includes(".")) continue;
const entry = fenceTaxonomy().byId.get(key);
if (entry !== undefined && surfaceMatchesDiag(entry, diag)) return text;
}
// 3. A fence covering the refused surface: the teaching rides the
// surface's own refusal (the non-static half of fence coverage).
for (const fence of profile.fences) {
if (fence.teaching === undefined) continue;
for (const s of fence.surfaces) {
if (surfaceMatchesDiag(s, diag)) return fence.teaching;
}
}
return undefined;
}
/** Attach the profile's teaching text to every library-mode refusal it
* names — by code, by manifest id, or through a fence — as the visibly
* attributed `note`. Diagnostics already carrying a note (SC4008 threads
* its fence's teaching at mint time) pass through unchanged. */
export function decorateLibraryRefusals(
diagnostics: ScrDiagnostic[],
profile: FenceProfileView,
): ScrDiagnostic[] {
if (Object.keys(profile.teachings).length === 0 && profile.fences.length === 0) return diagnostics;
return diagnostics.map((d) => {
if (d.note !== undefined) return d;
const teaching = teachingForRefusal(profile, d);
if (teaching === undefined) return d;
return { ...d, note: `from the '${profile.name}' profile: ${teaching}` };
});
}
+101 -20
View File
@@ -26,13 +26,24 @@
* "sidecar": { ... }, // ask-2 contract sidecar
* // (see below); absent =
* // no sidecar is emitted
* "determinism": { ... } // ask-5 surface, reserved;
* // only `teachings` and
* // `remediations` are read
* // today (the SC4004/SC4005
* // teaching rider + the
* "determinism": { ... } // the ask-5 surface:
* // `teachings`/`remediations`
* // maps (the SC4004/SC4005
* // rider generalized + the
* // structured trap-teaching
* // encoding's text sources)
* // and the `fences` array —
* // deny-by-manifest-id
* // entries, each exactly one
* // of id/prefix plus optional
* // teaching/remediation
* // (fence-eval.ts resolves
* // them against this
* // release's surface
* // manifest at load);
* // everything else under
* // `determinism` stays
* // reserved and ignored
* }
*
* The `sidecar` section (ask 2): when present, the same invocation that
@@ -77,6 +88,7 @@
import { readFileSync } from "node:fs";
import { dirname, isAbsolute, resolve } from "node:path";
import { libProfileDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js";
import { resolveLibraryFences, type LibraryFenceDecl, type ResolvedLibraryFence } from "./fence-eval.js";
/** Marshalling classes legal in PARAMETER position (v1). */
export const LIB_PARAM_CLASSES = ["f64", "bool", "string", "bytes", "u8", "u32", "i32"] as const;
@@ -165,6 +177,11 @@ export interface LibraryProfile {
* field is absent from the assembled bytes when the profile supplies
* none. Same reserved-byte validation as `teachings`. */
remediations: Readonly<Record<string, string>>;
/** The ask-5 determinism fences, resolved at load against this compiler
* release's surface manifest (fence-eval.ts): unknown ids/prefixes and
* unpoliceable static entries refused SC4001, never accepted as inert.
* Empty when the profile declares none. */
fences: readonly ResolvedLibraryFence[];
}
const C_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -373,17 +390,33 @@ export function loadLibraryProfile(
exportNames.add(e.export);
});
// Ask-5 surface: only the teachings and remediations riders are read
// today; everything else under `determinism` is reserved and ignored.
// Both maps feed the structured trap-teaching encoding, which reserves
// 0x01 (the version marker) and 0x1F (the field separator): a key or
// value carrying either would corrupt the assembled sink message, so
// both are refused at load. Keys are diagnostic codes validated ONLY
// as tokens free of the reserved bytes — embedder code spaces belong
// Ask-5 surface: the teachings/remediations riders and the fences
// array are read; everything else under `determinism` is reserved and
// ignored. Teaching/remediation TEXT refuses every control byte below
// 0x20 except newline (the ratified constraint: 0x01 and 0x1F are the
// structured trap encoding's marker and separator — either inside
// profile-authored text would corrupt the assembled sink message; the
// remaining control bytes have no honest place in teaching prose) and
// caps at 512 UTF-8 bytes per string. Keys are diagnostic codes
// validated ONLY as control-free tokens — embedder code spaces belong
// to the embedder (SC is the compiler registry's); membership is
// never checked here.
const teachings: Record<string, string> = {};
const remediations: Record<string, string> = {};
let fences: ResolvedLibraryFence[] = [];
const RESERVED_TEXT = /[\u0000-\u0009\u000b-\u001f]/;
const RESERVED_KEY = /[\u0000-\u001f]/;
const checkRiderText = (v: string, path: string): void => {
if (RESERVED_TEXT.test(v)) {
throw new ProfileError(
`'${path}' contains a reserved byte — control bytes below 0x20 are refused (0x01 and 0x1F are the structured trap encoding's marker and separator); teaching and remediation text must be plain UTF-8, newlines only`,
);
}
const bytes = new TextEncoder().encode(v).length;
if (bytes > 512) {
throw new ProfileError(`'${path}' is ${bytes} bytes — teaching and remediation strings cap at 512 bytes`);
}
};
const det = p["determinism"];
if (det !== undefined && det !== null && typeof det === "object" && !Array.isArray(det)) {
const readRider = (rider: "teachings" | "remediations", into: Record<string, string>): void => {
@@ -391,21 +424,58 @@ export function loadLibraryProfile(
if (t === undefined || t === null || typeof t !== "object" || Array.isArray(t)) return;
for (const [k, v] of Object.entries(t as Record<string, unknown>)) {
if (typeof v !== "string") continue;
if (/[\u0001\u001f]/.test(k)) {
if (RESERVED_KEY.test(k)) {
throw new ProfileError(
`'determinism.${rider}' key ${JSON.stringify(k)} contains a reserved byte — 0x01 and 0x1F are the structured trap encoding's marker and separator; a code must be a plain token`,
);
}
if (/[\u0001\u001f]/.test(v)) {
throw new ProfileError(
`'determinism.${rider}.${k}' contains a reserved byte — 0x01 and 0x1F are the structured trap encoding's marker and separator; teaching and remediation text must be plain UTF-8`,
);
}
checkRiderText(v, `determinism.${rider}.${k}`);
into[k] = v;
}
};
readRider("teachings", teachings);
readRider("remediations", remediations);
// The fences array (spec §1): each entry exactly one of id/prefix
// plus optional teaching/remediation, unknown fields refused (a typo
// here silently disarms a denial), and the selector resolved against
// this release's surface manifest — unknown ids/prefixes refuse
// loudly (ratified: fence profiles pin per compiler release).
const fencesRaw = (det as Record<string, unknown>)["fences"];
if (fencesRaw !== undefined && fencesRaw !== null) {
if (!Array.isArray(fencesRaw)) throw new ProfileError("'determinism.fences' must be an array");
const decls: LibraryFenceDecl[] = fencesRaw.map((f, i) => {
const path = `determinism.fences[${i}]`;
if (f === null || typeof f !== "object" || Array.isArray(f)) {
throw new ProfileError(`'${path}' must be an object`);
}
rejectUnknownKeys(f, path, ["id", "prefix", "teaching", "remediation"]);
const ff = f as Record<string, unknown>;
if ((ff["id"] === undefined) === (ff["prefix"] === undefined)) {
throw new ProfileError(`'${path}' must carry exactly one of 'id' or 'prefix'`);
}
const decl: LibraryFenceDecl = { path };
if (ff["id"] !== undefined) {
const id = req<string>(ff["id"], `${path}.id`, "string");
if (id === "") throw new ProfileError(`'${path}.id' must be a non-empty manifest entry id`);
decl.id = id;
} else {
const prefix = req<string>(ff["prefix"], `${path}.prefix`, "string");
if (prefix === "") throw new ProfileError(`'${path}.prefix' must be a non-empty manifest id prefix`);
decl.prefix = prefix;
}
for (const rider of ["teaching", "remediation"] as const) {
if (ff[rider] === undefined) continue;
const text = req<string>(ff[rider], `${path}.${rider}`, "string");
checkRiderText(text, `${path}.${rider}`);
decl[rider] = text;
}
return decl;
});
const resolved = resolveLibraryFences(decls);
if (!resolved.ok) throw new ProfileError(resolved.detail);
fences = resolved.fences;
}
}
const entry = isAbsolute(entryRel) ? entryRel : resolve(dirname(profilePath), entryRel);
@@ -426,6 +496,7 @@ export function loadLibraryProfile(
profileBytes: bytes,
teachings,
remediations,
fences,
},
};
} catch (e) {
@@ -443,7 +514,17 @@ export function profileTeaching(profile: LibraryProfile, code: string): string |
/** The profile's remediation text for one code — the structured
* trap-teaching message's optional fourth field. No shared-key fallback:
* a remediation names one code's fix. */
* a remediation names one code's fix. A fence entry's `remediation` feeds
* the same lookup through the codes its covered manifest entries carry
* (spec §3: "a remediation in a fence entry or a `remediations` map" —
* one data source for the field that already exists); an explicit map key
* wins over a fence's, and the first declared fence wins among fences. */
export function profileRemediation(profile: LibraryProfile, code: string): string | undefined {
return profile.remediations[code];
const direct = profile.remediations[code];
if (direct !== undefined) return direct;
for (const fence of profile.fences) {
if (fence.remediation === undefined) continue;
if (fence.surfaces.some((s) => s.code === code)) return fence.remediation;
}
return undefined;
}
+15
View File
@@ -19,6 +19,8 @@
* the clock-touching fixture attests deterministic: false
* - SC4009: unprojectable designations refuse with the declaration
* named, never guessed around
* - ask-5 §4: the full-fence worked-example profile compiles a clean
* program and the computed attestation agrees — deterministic: true
*/
import { createHash } from "node:crypto";
import { execFileSync, spawnSync } from "node:child_process";
@@ -311,6 +313,19 @@ identity stable: 1
expect(doc.integer_slots).toEqual([]);
});
test("ask-5 §4 invariant: the full-fence profile compiles and attests deterministic: true", async () => {
// The worked-example determinism profile (real manifest ids) over a
// clean program: every fence loads, none is reached, compilation
// succeeds, and the sidecar's COMPUTED attestation agrees with the
// fences' construction — deterministic: true. A program that compiles
// under full fences yet attests false is a bug in one of the two
// scans (spec §4), and this pin is what recognizes it.
const { doc } = await buildContract("fences-attest", emission);
expect(validateSidecar(doc)).toEqual([]);
expect(doc.deterministic).toBe(true);
expect(doc.async_free).toBe(true);
});
test("ask-3 §2 adversarial order shapes + §3 spread-union composition", async () => {
const { doc } = await buildContract("contract-order", emission);
expect(validateSidecar(doc)).toEqual([]);
+166 -10
View File
@@ -57,6 +57,15 @@
* refuses SC4020 naming the failed bar — never
* a generic SC1010/SC2013 — and builtins keep
* the SC4005 async_free story
* K14 determinism-fences the ask-5 deny-by-manifest-id surface: a
* reached fenced static surface refuses SC4008
* (id + name + attributed teaching note, both
* selector forms), unreached fences emit
* byte-identical code to a fence-free build,
* the generalized teachings map decorates any
* refusal by code or manifest id, and fencing
* an already-refusing surface only adds the
* note (its own code survives)
*/
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -524,7 +533,8 @@ let refusalCounter = 0;
async function refusal(
source: string,
profilePatch: Record<string, unknown>,
): Promise<{ code: string; message: string; hint?: string }[]> {
emission: Emission = "c",
): Promise<{ code: string; message: string; hint?: string; note?: string; file: string }[]> {
const outDir = join(cacheDir, `refusal-${refusalCounter++}`);
mkdirSync(outDir, { recursive: true });
const entry = join(outDir, "lib.ts");
@@ -533,7 +543,7 @@ async function refusal(
profile_format: 1,
name: "refusal-fixture",
entry,
emission: "c",
emission,
abi: {
prefix: "kx_",
init_symbol: "kx_init",
@@ -549,7 +559,13 @@ async function refusal(
const result = await compileLibrary({ profilePath, outDir });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("unreachable");
return result.diagnostics.map((d) => ({ code: d.code, message: d.message, ...(d.hint !== undefined ? { hint: d.hint } : {}) }));
return result.diagnostics.map((d) => ({
code: d.code,
message: d.message,
...(d.hint !== undefined ? { hint: d.hint } : {}),
...(d.note !== undefined ? { note: d.note } : {}),
file: d.loc.file,
}));
}
describe("K9: library-mode refusals", () => {
@@ -580,24 +596,26 @@ describe("K9: library-mode refusals", () => {
expect(diags[0]!.message).toContain("2 parameter(s)");
});
test("SC4004: a mapped async export, with the profile teaching", async () => {
test("SC4004: a mapped async export, with the profile teaching as the attributed note", async () => {
const diags = await refusal(`export async function tick(): Promise<number> { return 1; }\n`, {
exports: [{ export: "tick", symbol: "kx_tick", params: [], returns: "f64" }],
determinism: { teachings: { async: "use the host scheduler entry instead" } },
});
expect(diags[0]!.code).toBe("SC4004");
expect(diags[0]!.hint).toContain("synchronous facade");
expect(diags[0]!.hint).toContain("use the host scheduler entry instead");
// The teaching arrives as its own visibly-attributed note — the
// tool's hint stays the tool's.
expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: use the host scheduler entry instead");
});
test("SC4005: a timer anywhere in the graph, teaching verbatim", async () => {
test("SC4005: a timer anywhere in the graph, teaching as the attributed note", async () => {
const diags = await refusal(`setTimeout(() => {}, 1);\nexport function f(): number { return 1; }\n`, {
exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }],
determinism: { teachings: { SC4005: "schedule through the embedder frame loop" } },
});
expect(diags[0]!.code).toBe("SC4005");
expect(diags[0]!.message).toContain("timers");
expect(diags[0]!.hint).toContain("schedule through the embedder frame loop");
expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: schedule through the embedder frame loop");
});
test("SC4007: a generic export", async () => {
@@ -620,7 +638,7 @@ async function npmRefusal(
entryFile: string,
exports: unknown[],
profilePatch: Record<string, unknown> = {},
): Promise<{ code: string; message: string; hint?: string; file: string }[]> {
): Promise<{ code: string; message: string; hint?: string; note?: string; file: string }[]> {
const outDir = join(cacheDir, `npm-refusal-${npmRefusalCounter++}`);
mkdirSync(outDir, { recursive: true });
const profile = {
@@ -647,6 +665,7 @@ async function npmRefusal(
code: d.code,
message: d.message,
...(d.hint !== undefined ? { hint: d.hint } : {}),
...(d.note !== undefined ? { note: d.note } : {}),
file: d.loc.file,
}));
}
@@ -683,14 +702,14 @@ describe("K13: library-mode npm refusals (SC4020, static-or-refuse)", () => {
expect(diags[0]!.message).toContain("no own .d.ts");
});
test("the profile teaching rides the SC4020 hint", async () => {
test("the profile teaching rides SC4020 as the attributed note", async () => {
const diags = await npmRefusal(
"lib-markers.ts",
[{ export: "f", symbol: "kx_f", params: [], returns: "string" }],
{ determinism: { teachings: { SC4020: "request the vendored copy from the embedder SDK" } } },
);
expect(diags[0]!.code).toBe("SC4020");
expect(diags[0]!.hint).toContain("request the vendored copy from the embedder SDK");
expect(diags[0]!.note).toBe("from the 'npm-refusal-fixture' profile: request the vendored copy from the embedder SDK");
});
test("a builtin pulling the event loop keeps the SC4005 story, not a new fence", async () => {
@@ -702,3 +721,140 @@ describe("K13: library-mode npm refusals (SC4020, static-or-refuse)", () => {
expect(diags[0]!.message).toContain("timers");
});
});
/* ── K14: the ask-5 determinism fences (spec §1–§2) ───────────────────────
* The profile's `determinism.fences` deny surfaces by surface-manifest id
* at compile time. Reached STATIC surfaces refuse SC4008 with the fence's
* teaching as the attributed note; fenced surfaces the static tier
* refuses anyway keep their own code with the teaching riding it; and the
* generalized `teachings` map attaches text to any refusal by code or
* manifest id. Both emissions run the refusal cases too — the refusal is
* pre-emission, but the profile pins the emission and conformance asserts
* the answer is emission-invariant. */
const WORKED_FENCES = [
{ id: "stdlib.math.random", teaching: "randomness is an effect: request it from the host and receive it as a Msg." },
{ prefix: "node-builtin.fs.", teaching: "files are effects: declare reads and writes as commands the host executes." },
{ prefix: "node-builtin.net.", teaching: "network is an effect: declare requests as commands; responses arrive as Msgs." },
{ prefix: "node-builtin.os.", teaching: "machine identity is an effect: ask the host." },
{ prefix: "node-builtin.crypto.", teaching: "randomness and digests come from the host." },
];
describe.each(EMISSIONS)("K14: determinism fences, %s emission", (emission) => {
test("a reached id-fenced surface refuses SC4008 with code, id, and attributed teaching", async () => {
const diags = await refusal(
`export function roll(): number { return Math.random(); }\n`,
{
exports: [{ export: "roll", symbol: "kx_roll", params: [], returns: "f64" }],
determinism: { fences: WORKED_FENCES },
},
emission,
);
expect(diags.map((d) => d.code)).toEqual(["SC4008"]);
expect(diags[0]!.message).toContain("'stdlib.math.random'");
expect(diags[0]!.message).toContain("Math.random");
expect(diags[0]!.note).toBe(
"from the 'refusal-fixture' profile: randomness is an effect: request it from the host and receive it as a Msg.",
);
// Anchored at the reaching construct, not the profile file.
expect(diags[0]!.file.endsWith("lib.ts")).toBe(true);
});
test("a reached prefix-fenced surface refuses SC4008 naming the covered member id", async () => {
const diags = await refusal(
`import { readFileSync } from "node:fs";\nexport function f(): number { return readFileSync("x", "utf8").length; }\n`,
{
exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }],
determinism: { fences: WORKED_FENCES },
},
emission,
);
expect(diags.map((d) => d.code)).toEqual(["SC4008"]);
expect(diags[0]!.message).toContain("'node-builtin.fs.readFileSync'");
expect(diags[0]!.note).toContain("files are effects");
});
test("unreached fences compile at zero cost: the emitted code is byte-identical", async () => {
// ONE entry file feeds both builds (emitted C/LLVM embeds the entry's
// path in provenance comments; the fence's cost is what's measured,
// not the build directory's spelling).
const entryDir = join(cacheDir, `fence-zerocost-${emission}`);
mkdirSync(entryDir, { recursive: true });
const entry = join(entryDir, "lib.ts");
writeFileSync(entry, `export function f(): number { return 41 + 1; }\n`);
const emitted: string[] = [];
for (const fenced of [false, true]) {
const outDir = join(cacheDir, `fence-zerocost-${emission}-${fenced ? "fenced" : "plain"}`);
mkdirSync(outDir, { recursive: true });
const profilePath = join(outDir, "profile.json");
writeFileSync(
profilePath,
JSON.stringify({
profile_format: 1,
name: "fence-zerocost",
entry,
emission,
abi: {
prefix: "kx_",
init_symbol: "kx_init",
sink_register_symbol: "kx_set_panic_sink",
collect_symbol: null,
result_reset_symbol: null,
},
exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }],
...(fenced ? { determinism: { fences: WORKED_FENCES } } : {}),
}),
);
const result = await compileLibrary({ profilePath, outDir });
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
emitted.push(readFileSync(result.cPath, "utf8"));
}
expect(emitted[1]).toBe(emitted[0]);
});
test("a code-keyed teachings entry rides a non-fence refusal as the attributed note", async () => {
const diags = await refusal(
`export function f(): number { return "abc".normalize().length; }\n`,
{
exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }],
determinism: { teachings: { SC2020: "host strings arrive normalized; never renormalize in a core" } },
},
emission,
);
expect(diags[0]!.code).toBe("SC2020");
expect(diags[0]!.note).toBe(
"from the 'refusal-fixture' profile: host strings arrive normalized; never renormalize in a core",
);
});
test("a manifest-id-keyed teachings entry attaches to that surface's own refusal", async () => {
const diags = await refusal(
`export function f(): number { return Math.sin(1); }\n`,
{
exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }],
determinism: { teachings: { "stdlib.math.sin": "trig runs in the host; request it as an effect" } },
},
emission,
);
// The surface's own code, not a fence code: the id key attaches text
// to the refusal that already fires.
expect(diags[0]!.code).toBe("SC2012");
expect(diags[0]!.message).toContain("Math.sin");
expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: trig runs in the host; request it as an effect");
});
test("fencing a surface the static tier refuses anyway changes only the message", async () => {
const diags = await refusal(
`export function f(): number { return Math.sin(1); }\n`,
{
exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }],
determinism: { fences: [{ id: "stdlib.math.sin", teaching: "trig is host math" }] },
},
emission,
);
// The existing refusal's code survives — the fence never re-codes a
// surface that already refuses; its teaching rides as the note.
expect(diags[0]!.code).toBe("SC2012");
expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: trig is host math");
});
});
+179 -1
View File
@@ -102,7 +102,9 @@ describe("library profile validation", () => {
test("teaching and remediation text reject the encoding's reserved bytes", () => {
// 0x01 is the structured message's version marker, 0x1F its field
// separator: either byte inside profile-authored text would corrupt
// the assembled sink message, so the load refuses (SC4001).
// the assembled sink message, so the load refuses (SC4001). The full
// ratified constraint bans every control byte below 0x20 except
// newline in TEXT (and every control byte, newline included, in keys).
expectSc4001(
{ ...good, determinism: { teachings: { SC4012: "bad \u0001 marker" } } },
"determinism.teachings.SC4012",
@@ -115,6 +117,28 @@ describe("library profile validation", () => {
{ ...good, determinism: { teachings: { "NS\u001f12": "text is fine" } } },
"reserved byte",
);
expectSc4001(
{ ...good, determinism: { teachings: { SC4012: "tabs\tare control bytes" } } },
"determinism.teachings.SC4012",
);
const withNewline = loadLibraryProfile(
writeProfile({ ...good, determinism: { teachings: { SC4012: "line one\nline two" } } }),
);
expect(withNewline.ok).toBe(true);
});
test("teaching and remediation text cap at 512 bytes, refuse over", () => {
const atCap = loadLibraryProfile(
writeProfile({ ...good, determinism: { teachings: { SC4012: "x".repeat(512) } } }),
);
expect(atCap.ok).toBe(true);
expectSc4001({ ...good, determinism: { teachings: { SC4012: "x".repeat(513) } } }, "512 bytes");
expectSc4001(
{ ...good, determinism: { remediations: { SC4012: "x".repeat(513) } } },
"determinism.remediations.SC4012",
);
// The cap counts UTF-8 BYTES, not code points.
expectSc4001({ ...good, determinism: { teachings: { SC4012: "\u00e9".repeat(300) } } }, "512");
});
test("parse error", () => expectSc4001("{ not json", "not valid JSON"));
@@ -231,3 +255,157 @@ describe("library profile sidecar section", () => {
test("an absolute sidecar path refuses", () =>
expectSc4001({ ...good, sidecar: { ...goodSidecar, path: "/tmp/contract.json" } }, "sidecar.path"));
});
/* ── the ask-5 fences array ────────────────────────────────────────────────
* Selector strictness is RATIFIED: fence profiles pin per compiler
* release, so an id or prefix matching nothing in this release's surface
* manifest refuses SC4001 at load — there is no forward-compatible
* acceptance of unknown selectors, and no silently-inert fence. Static
* surfaces no detector can police refuse the same way, with the reason
* named (folded constants, desugared methods). */
describe("library profile fences", () => {
test("well-formed fences resolve: id and prefix selectors, covered surfaces, detectors", () => {
const r = loadLibraryProfile(
writeProfile({
...good,
determinism: {
fences: [
{ id: "stdlib.math.random", teaching: "randomness is an effect", remediation: "ask the host" },
{ prefix: "node-builtin.fs.", teaching: "files are effects" },
{ id: "stdlib.math.sin", teaching: "trig is host math", remediation: "request it as an effect" },
],
},
}),
);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.profile.fences).toHaveLength(3);
// The id fence covers exactly its entry; math.random is static, so it
// carries a detector (the compile-time denial's reach witness).
expect(r.profile.fences[0]!.declared).toBe("id 'stdlib.math.random'");
expect(r.profile.fences[0]!.surfaces).toHaveLength(1);
expect(r.profile.fences[0]!.surfaces[0]!.id).toBe("stdlib.math.random");
expect(r.profile.fences[0]!.surfaces[0]!.detector).toBeDefined();
// The prefix fence covers the whole fs member family.
const fsIds = r.profile.fences[1]!.surfaces.map((s) => s.id);
expect(fsIds).toContain("node-builtin.fs.readFileSync");
expect(fsIds).toContain("node-builtin.fs.promises.readFile");
// A fenced dynamic-only surface carries its own refusal code and no
// detector: the teaching rides the refusal that already fires.
const sin = r.profile.fences[2]!.surfaces[0]!;
expect(sin.code).toBe("SC2012");
expect(sin.detector).toBeUndefined();
});
test("a fence remediation feeds the trap-remediation lookup through covered codes", () => {
const r = loadLibraryProfile(
writeProfile({
...good,
determinism: {
remediations: { SC2012: "the explicit map key wins" },
fences: [
{ id: "stdlib.math.sin", remediation: "request it as an effect" },
{ id: "node-builtin.crypto.createHash", remediation: "digests come from the host" },
],
},
}),
);
expect(r.ok).toBe(true);
if (!r.ok) return;
// Explicit remediations keys win over fence-supplied text.
expect(profileRemediation(r.profile, "SC2012")).toBe("the explicit map key wins");
// A fence's remediation answers for its covered entries' codes
// (crypto.createHash is SC2020) — spec §3's "in a fence entry or a
// remediations map", one lookup either way.
expect(profileRemediation(r.profile, "SC2020")).toBe("digests come from the host");
expect(profileRemediation(r.profile, "SC4014")).toBeUndefined();
});
test("an id matching nothing in this release's manifest refuses (ratified strictness)", () => {
expectSc4001(
{ ...good, determinism: { fences: [{ id: "stdlib.date.now" }] } },
"determinism.fences[0].id",
);
expectSc4001(
{ ...good, determinism: { fences: [{ id: "stdlib.date.now" }] } },
"names no entry",
);
});
test("a prefix matching nothing refuses — the spec's illustrative spelling included", () => {
// The worked example's 'node.fs.' is illustrative prose; the real
// taxonomy spells 'node-builtin.fs.', and the strict refusal catches
// the drift instead of shipping an inert fence.
expectSc4001(
{ ...good, determinism: { fences: [{ prefix: "node.fs." }] } },
"determinism.fences[0].prefix",
);
});
test("exactly one of id/prefix per fence", () => {
expectSc4001(
{ ...good, determinism: { fences: [{ id: "stdlib.math.random", prefix: "node-builtin.fs." }] } },
"exactly one of 'id' or 'prefix'",
);
expectSc4001(
{ ...good, determinism: { fences: [{ teaching: "no selector" }] } },
"exactly one of 'id' or 'prefix'",
);
});
test("unknown fields inside a fence entry refuse (a typo silently disarms a denial)", () => {
expectSc4001(
{ ...good, determinism: { fences: [{ id: "stdlib.math.random", teachng: "typo" }] } },
"unknown field 'determinism.fences[0].teachng'",
);
});
test("fence teaching/remediation strings share the reserved-byte and 512-byte rules", () => {
expectSc4001(
{ ...good, determinism: { fences: [{ id: "stdlib.math.random", teaching: "bad \u0001 marker" }] } },
"determinism.fences[0].teaching",
);
expectSc4001(
{ ...good, determinism: { fences: [{ id: "stdlib.math.random", remediation: "x".repeat(513) }] } },
"determinism.fences[0].remediation",
);
});
test("a compile-time-folded constant cannot be id-fenced; a prefix exempts it", () => {
// os.EOL folds to a per-binary literal: nothing at runtime reads it,
// so an id fence naming it would be a lie — refused with the reason.
expectSc4001(
{ ...good, determinism: { fences: [{ id: "node-builtin.os.EOL" }] } },
"fold",
);
// The family sweep stays usable: the prefix covers the os members and
// exempts the folded constant.
const r = loadLibraryProfile(
writeProfile({ ...good, determinism: { fences: [{ prefix: "node-builtin.os." }] } }),
);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.profile.fences[0]!.surfaces.map((s) => s.id)).not.toContain("node-builtin.os.EOL");
expect(r.profile.fences[0]!.surfaces.map((s) => s.id)).toContain("node-builtin.os.homedir");
});
test("a desugared surface no detector can police refuses, id and prefix alike", () => {
expectSc4001(
{ ...good, determinism: { fences: [{ id: "stdlib.array.map" }] } },
"desugars",
);
expectSc4001(
{ ...good, determinism: { fences: [{ prefix: "stdlib.array." }] } },
"cannot be fenced",
);
});
test("no fences array means no fences", () => {
const r = loadLibraryProfile(writeProfile(good));
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.profile.fences).toEqual([]);
});
});
+37
View File
@@ -0,0 +1,37 @@
// The ask-5 §4 invariant fixture: the worked-example determinism profile
// (translated into this release's real manifest ids) over a program that
// COMPILES under it. Every fence is present and unreached, and the
// sidecar's computed attestation must come out `deterministic: true` —
// with fences covering the ambient-nondeterminism surfaces, determinism
// holds by construction for whatever compiles, so a program that compiles
// under full fences yet attests false is a bug in one of the two scans,
// never ambiguity.
export interface Model {
total: number;
label: string;
}
export type Msg = { kind: "add"; value: number } | { kind: "label_set"; value: string };
export function init(): Model {
return { total: 0, label: "start" };
}
export function update(m: Model, msg: Msg): Model {
switch (msg.kind) {
case "add":
return { total: m.total + msg.value, label: m.label };
default:
return { total: m.total, label: msg.value };
}
}
let state: Model = init();
export function boot(delta: number): number {
state = update(state, { kind: "add", value: Math.floor(delta) });
return state.total;
}
console.log("fences-attest ready");
@@ -0,0 +1,37 @@
{
"profile_format": 1,
"name": "conformance-fences-attest",
"entry": "lib.ts",
"emission": "llvm",
"abi": {
"prefix": "kf_",
"init_symbol": "kf_init",
"sink_register_symbol": "kf_set_panic_sink",
"collect_symbol": null,
"result_reset_symbol": null
},
"exports": [
{ "export": "boot", "symbol": "kf_boot", "params": ["f64"], "returns": "f64" }
],
"sidecar": {
"wire_version": 1,
"abi_version": 1,
"snapshot_format": 1,
"build_id_symbol": "kf_build_id",
"abi_version_symbol": "kf_abi_version",
"model": "Model",
"msg": "Msg"
},
"determinism": {
"teachings": {
"SC4005": "async is unavailable in this compilation mode; the host executes effects — declare a command instead."
},
"fences": [
{ "id": "stdlib.math.random", "teaching": "randomness is an effect: request it from the host and receive it as a Msg." },
{ "prefix": "node-builtin.fs.", "teaching": "files are effects: declare reads and writes as commands the host executes." },
{ "prefix": "node-builtin.net.", "teaching": "network is an effect: declare requests as commands; responses arrive as Msgs." },
{ "prefix": "node-builtin.os.", "teaching": "machine identity is an effect: ask the host." },
{ "prefix": "node-builtin.crypto.", "teaching": "randomness and digests come from the host." }
]
}
}