From 3718be71fdf2b902197e6527078ae1c00cbebf0c Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 23 Aug 2026 07:39:04 -0400 Subject: [PATCH 1/2] feat(handoff): issue/hypothesis seed schemas, round-trip templates, adversarial corpus (#499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed cross-campaign handoff seeds per spec-20260822 D3 / handoff_roundtrip_pass: - committed JSON Schemas (handoff-seeds/): issue seed (title, motivation, evidence pointers that must resolve, suggested_repo, suggested_tier; kind const "issue") and hypothesis seed (observation, evidence, suggested_experiment; kind const "hypothesis"), both additionalProperties:false so a kind/field-set masquerade is refused mechanically - committed render templates + src render/extract: issue seed renders the write-an-issue decision surface, hypothesis seed renders the vault hypothesis card; extractors re-extract the full declared field set (field-equal round-trip over the committed corpus) - evidence pointers resolve at validation time against a root — dead pointers are refused, not warned, with the violated JSON-Pointer path named - fixture corpus: 4 valid (2 per kind) + 4 adversarial (dead pointer, both masquerade directions, missing required field) + fixture-local evidence root - zero new dependencies (yaml + node builtins); zero blocklisted proper nouns in committed slice files (grep-tested) --- .../handoff-seeds/hypothesis-seed.schema.json | 22 + .../handoff-seeds/hypothesis-seed.template.md | 15 + .../handoff-seeds/issue-seed.schema.json | 24 + .../handoff-seeds/issue-seed.template.md | 25 + packages/extension/src/handoff_seeds.ts | 527 ++++++++++++++++++ .../hypothesis-masquerade-no-experiment.json | 10 + .../hypothesis-missing-evidence.json | 5 + .../issue-dead-evidence-pointer.json | 11 + .../adversarial/issue-masquerade-no-repo.json | 8 + ...nt-20260821-120000-blockade-sector-rank.md | 19 + ...xperiment-20260822-193053-rwa-breakdown.md | 18 + ...0260822-063401-drift-detector-threshold.md | 16 + ...60822-063402-noise-normalized-detectors.md | 16 + ...ight-20260822-063403-bending-energy-rwa.md | 16 + .../valid/hypothesis-blockade-rank.json | 8 + .../valid/hypothesis-implicit-drag.json | 9 + .../valid/issue-detector-thresholds.json | 11 + .../valid/issue-rwa-bending-energy.json | 11 + packages/extension/test/handoff_seeds.test.ts | 250 +++++++++ 19 files changed, 1021 insertions(+) create mode 100644 packages/extension/handoff-seeds/hypothesis-seed.schema.json create mode 100644 packages/extension/handoff-seeds/hypothesis-seed.template.md create mode 100644 packages/extension/handoff-seeds/issue-seed.schema.json create mode 100644 packages/extension/handoff-seeds/issue-seed.template.md create mode 100644 packages/extension/src/handoff_seeds.ts create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-masquerade-no-experiment.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-missing-evidence.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/issue-dead-evidence-pointer.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/issue-masquerade-no-repo.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260821-120000-blockade-sector-rank.md create mode 100644 packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260822-193053-rwa-breakdown.md create mode 100644 packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063401-drift-detector-threshold.md create mode 100644 packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063402-noise-normalized-detectors.md create mode 100644 packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063403-bending-energy-rwa.md create mode 100644 packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-blockade-rank.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-implicit-drag.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/valid/issue-detector-thresholds.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/valid/issue-rwa-bending-energy.json create mode 100644 packages/extension/test/handoff_seeds.test.ts diff --git a/packages/extension/handoff-seeds/hypothesis-seed.schema.json b/packages/extension/handoff-seeds/hypothesis-seed.schema.json new file mode 100644 index 00000000..07e9e470 --- /dev/null +++ b/packages/extension/handoff-seeds/hypothesis-seed.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "amicode:handoff-seeds/hypothesis-seed.schema.json", + "title": "Hypothesis handoff seed (dev closes → research picks up)", + "description": "Typed cross-campaign currency for a code-side observation that deserves an experiment. Rendered to the vault hypothesis-card convention by the committed hypothesis-seed template; filed by the receiving side into the vault. Evidence pointers are vault-relative paths that must resolve at validation time. String fields are single-line so the template render is total.", + "type": "object", + "properties": { + "kind": { + "const": "hypothesis", + "description": "Fixed discriminator; bound mechanically to this field set (the masquerade test)." + }, + "observation": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" }, + "evidence": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" } + }, + "suggested_experiment": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" } + }, + "required": ["kind", "observation", "evidence", "suggested_experiment"], + "additionalProperties": false +} diff --git a/packages/extension/handoff-seeds/hypothesis-seed.template.md b/packages/extension/handoff-seeds/hypothesis-seed.template.md new file mode 100644 index 00000000..33268e1d --- /dev/null +++ b/packages/extension/handoff-seeds/hypothesis-seed.template.md @@ -0,0 +1,15 @@ +--- +type: hypothesis +date: {{date}} +source: handoff-seed +status: open +evidence: +{{evidence}} +tags: [hypothesis, handoff] +--- + + + +# {{observation}} + +**Suggested experiment:** {{suggested_experiment}} diff --git a/packages/extension/handoff-seeds/issue-seed.schema.json b/packages/extension/handoff-seeds/issue-seed.schema.json new file mode 100644 index 00000000..1b6430e6 --- /dev/null +++ b/packages/extension/handoff-seeds/issue-seed.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "amicode:handoff-seeds/issue-seed.schema.json", + "title": "Issue handoff seed (research closes → dev picks up)", + "description": "Typed cross-campaign currency for a research finding that needs code. Rendered to the write-an-issue decision surface by the committed issue-seed template; filed by the receiving side through the normal issue flow. Evidence pointers are vault-relative paths that must resolve at validation time. String fields are single-line so the template render is total.", + "type": "object", + "properties": { + "kind": { + "const": "issue", + "description": "Fixed discriminator; bound mechanically to this field set (the masquerade test)." + }, + "title": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" }, + "motivation": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" }, + "evidence": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" } + }, + "suggested_repo": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" }, + "suggested_tier": { "enum": ["chore", "standard", "PRD"] } + }, + "required": ["kind", "title", "motivation", "evidence", "suggested_repo", "suggested_tier"], + "additionalProperties": false +} diff --git a/packages/extension/handoff-seeds/issue-seed.template.md b/packages/extension/handoff-seeds/issue-seed.template.md new file mode 100644 index 00000000..84ec70da --- /dev/null +++ b/packages/extension/handoff-seeds/issue-seed.template.md @@ -0,0 +1,25 @@ +--- +kind: {{kind}} +suggested_repo: {{suggested_repo}} +suggested_tier: {{suggested_tier}} +evidence: +{{evidence}} +--- + + + +# {{title}} + +> [!IMPORTANT] +> **Problem** — {{motivation}} +> **Approach** — Typed handoff from a research campaign; file through the normal issue flow at the suggested tier. +> **Scope** — in: the seeded ask · out: filing agency (the receiving side files it) + +## Acceptance Criteria +- [ ] The filed issue carries the seed's evidence pointers, resolved at handoff time + +## Prior Art +- Evidence pointers ride the frontmatter `evidence` list; each resolved at validation time. + +## Source +- Handoff issue seed, rendered from the committed template. diff --git a/packages/extension/src/handoff_seeds.ts b/packages/extension/src/handoff_seeds.ts new file mode 100644 index 00000000..e84222af --- /dev/null +++ b/packages/extension/src/handoff_seeds.ts @@ -0,0 +1,527 @@ +// Handoff seeds — typed cross-campaign currency (spec-20260822 D3, issue #499). +// +// Research closes by emitting an ISSUE seed (rendered to the write-an-issue +// decision surface); dev closes by emitting a HYPOTHESIS seed (rendered to a +// vault hypothesis card). Three mechanical contracts live here: +// +// 1. SCHEMA — each seed kind has a committed JSON Schema (handoff-seeds/); +// the copies below are the enforcement source and the test suite asserts +// the two never drift. The seed's `kind` and its required field set are +// bound: a fixture whose kind and field set disagree is refused (the +// masquerade test). +// 2. EVIDENCE — pointers are vault-relative paths that must RESOLVE at +// validation time against a root; a seed citing a nonexistent artifact is +// refused, not warned. +// 3. ROUND-TRIP — seed → committed-template render → re-extract → +// field-equal over the full declared field set. Rendering is template +// substitution (single pass, no re-scanning), so inserted values are +// never re-interpreted. +// +// Filing agency stays with the receiving side (protocol discipline): nothing +// here auto-files an issue or writes a card. + +import { existsSync } from "node:fs"; +import { resolve as resolvePath, sep } from "node:path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type SuggestedTier = "chore" | "standard" | "PRD"; + +export interface IssueSeed { + readonly kind: "issue"; + readonly title: string; + readonly motivation: string; + /** Vault-relative pointers; each must resolve under the validation root. */ + readonly evidence: readonly string[]; + readonly suggested_repo: string; + readonly suggested_tier: SuggestedTier; +} + +export interface HypothesisSeed { + readonly kind: "hypothesis"; + readonly observation: string; + readonly evidence: readonly string[]; + readonly suggested_experiment: string; +} + +export type HandoffSeed = IssueSeed | HypothesisSeed; + +/** One violated schema location. `path` is a JSON Pointer, e.g. "/evidence/1". */ +export interface SchemaIssue { + readonly path: string; + readonly message: string; +} + +export interface HandoffSeedVerdict { + readonly ok: boolean; + readonly seed?: HandoffSeed; + /** Every violation, each naming its schema path. Empty iff ok. */ + readonly issues: readonly SchemaIssue[]; +} + +// ─── Schemas (enforcement copies of the committed handoff-seeds/ files) ────── + +export interface JsonSchema { + readonly [keyword: string]: unknown; +} + +/** Single-line guard: string fields render as one template line. */ +const SINGLE_LINE = "^[^\\n\\r]*$"; + +export const ISSUE_SEED_SCHEMA: JsonSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "amicode:handoff-seeds/issue-seed.schema.json", + title: "Issue handoff seed (research closes → dev picks up)", + description: + "Typed cross-campaign currency for a research finding that needs code. Rendered to the write-an-issue decision surface by the committed issue-seed template; filed by the receiving side through the normal issue flow. Evidence pointers are vault-relative paths that must resolve at validation time. String fields are single-line so the template render is total.", + type: "object", + properties: { + kind: { + const: "issue", + description: + "Fixed discriminator; bound mechanically to this field set (the masquerade test).", + }, + title: { type: "string", minLength: 1, pattern: SINGLE_LINE }, + motivation: { type: "string", minLength: 1, pattern: SINGLE_LINE }, + evidence: { + type: "array", + minItems: 1, + items: { type: "string", minLength: 1, pattern: SINGLE_LINE }, + }, + suggested_repo: { type: "string", minLength: 1, pattern: SINGLE_LINE }, + suggested_tier: { enum: ["chore", "standard", "PRD"] }, + }, + required: ["kind", "title", "motivation", "evidence", "suggested_repo", "suggested_tier"], + additionalProperties: false, +}; + +export const HYPOTHESIS_SEED_SCHEMA: JsonSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "amicode:handoff-seeds/hypothesis-seed.schema.json", + title: "Hypothesis handoff seed (dev closes → research picks up)", + description: + "Typed cross-campaign currency for a code-side observation that deserves an experiment. Rendered to the vault hypothesis-card convention by the committed hypothesis-seed template; filed by the receiving side into the vault. Evidence pointers are vault-relative paths that must resolve at validation time. String fields are single-line so the template render is total.", + type: "object", + properties: { + kind: { + const: "hypothesis", + description: + "Fixed discriminator; bound mechanically to this field set (the masquerade test).", + }, + observation: { type: "string", minLength: 1, pattern: SINGLE_LINE }, + evidence: { + type: "array", + minItems: 1, + items: { type: "string", minLength: 1, pattern: SINGLE_LINE }, + }, + suggested_experiment: { type: "string", minLength: 1, pattern: SINGLE_LINE }, + }, + required: ["kind", "observation", "evidence", "suggested_experiment"], + additionalProperties: false, +}; + +// ─── Mini JSON-Schema engine (the subset the seed schemas use) ─────────────── + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function typeMatches(value: unknown, type: string): boolean { + switch (type) { + case "object": + return isPlainObject(value); + case "array": + return Array.isArray(value); + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && !Number.isNaN(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "null": + return value === null; + default: + return true; // unknown type keyword: the schemas are ours, nothing to check + } +} + +function describeType(value: unknown): string { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; +} + +/** Validate `value` against a JSON Schema (subset: type, const, enum, + * minLength, pattern, minItems, items, properties, required, + * additionalProperties). Returns every violation with its JSON-Pointer path. */ +export function validateAgainstSchema( + value: unknown, + schema: JsonSchema, + path = "", +): SchemaIssue[] { + const here = path === "" ? "/" : path; + const at = (segment: string) => `${path === "" ? "" : path}/${segment}`; + const issues: SchemaIssue[] = []; + + const type = schema.type; + if (typeof type === "string" && !typeMatches(value, type)) { + return [{ path: here, message: `expected type ${type}, got ${describeType(value)}` }]; + } + + if ("const" in schema && value !== schema.const) { + issues.push({ path: here, message: `expected const ${JSON.stringify(schema.const)}` }); + } + + const enumeration = schema.enum; + if (Array.isArray(enumeration) && !enumeration.some((c) => c === value)) { + issues.push({ path: here, message: `expected one of ${JSON.stringify(enumeration)}` }); + } + + if (typeof value === "string") { + const minLength = schema.minLength; + if (typeof minLength === "number" && value.length < minLength) { + issues.push({ path: here, message: `shorter than minLength ${minLength}` }); + } + const pattern = schema.pattern; + if (typeof pattern === "string" && !new RegExp(pattern).test(value)) { + issues.push({ path: here, message: `does not match pattern ${pattern}` }); + } + } + + if (Array.isArray(value)) { + const minItems = schema.minItems; + if (typeof minItems === "number" && value.length < minItems) { + issues.push({ path: here, message: `fewer than minItems ${minItems}` }); + } + const items = schema.items; + if (isPlainObject(items)) { + value.forEach((item, index) => { + issues.push(...validateAgainstSchema(item, items, `${path}/${index}`)); + }); + } + } + + if (isPlainObject(value)) { + const required = schema.required; + if (Array.isArray(required)) { + for (const key of required) { + if (!(key in value)) { + issues.push({ path: at(key), message: `required field "${key}" is missing` }); + } + } + } + const properties = schema.properties; + if (isPlainObject(properties)) { + for (const [key, subschema] of Object.entries(properties)) { + if (isPlainObject(subschema) && key in value) { + issues.push(...validateAgainstSchema(value[key], subschema, at(key))); + } + } + } + if (schema.additionalProperties === false && isPlainObject(properties)) { + for (const key of Object.keys(value)) { + if (!(key in properties)) { + issues.push({ + path: at(key), + message: `additional property "${key}" is not allowed by this seed kind (kind/field-set disagreement)`, + }); + } + } + } + } + + return issues; +} + +// ─── Validation (schema + evidence-pointer resolution) ─────────────────────── + +/** A pointer resolves iff it names an existing path under `root` (wiki-link + * brackets tolerated, traversal and absolute paths refused). */ +function pointerResolves(root: string, pointer: string): boolean { + const bare = + pointer.startsWith("[[") && pointer.endsWith("]]") ? pointer.slice(2, -2) : pointer; + const resolved = resolvePath(root, bare); + const rootAbs = resolvePath(root); + if (resolved !== rootAbs && !resolved.startsWith(rootAbs + sep)) return false; + return existsSync(resolved); +} + +function checkEvidencePointers( + evidence: readonly string[], + root: string, +): SchemaIssue[] { + const issues: SchemaIssue[] = []; + evidence.forEach((pointer, index) => { + if (!pointerResolves(root, pointer)) { + issues.push({ + path: `/evidence/${index}`, + message: `evidence pointer does not resolve under the validation root: "${pointer}"`, + }); + } + }); + return issues; +} + +function validateSeedKind( + candidate: unknown, + schema: JsonSchema, + evidenceRoot: string, +): HandoffSeedVerdict { + const issues = validateAgainstSchema(candidate, schema); + const evidence = isPlainObject(candidate) ? candidate.evidence : undefined; + if (Array.isArray(evidence) && evidence.every((e) => typeof e === "string")) { + issues.push(...checkEvidencePointers(evidence as string[], evidenceRoot)); + } + if (issues.length > 0) return { ok: false, issues }; + return { ok: true, seed: candidate as HandoffSeed, issues: [] }; +} + +export function validateIssueSeed( + candidate: unknown, + evidenceRoot: string, +): HandoffSeedVerdict { + return validateSeedKind(candidate, ISSUE_SEED_SCHEMA, evidenceRoot); +} + +export function validateHypothesisSeed( + candidate: unknown, + evidenceRoot: string, +): HandoffSeedVerdict { + return validateSeedKind(candidate, HYPOTHESIS_SEED_SCHEMA, evidenceRoot); +} + +/** Validate a seed of either kind; dispatch is mechanical on the `kind` field. */ +export function validateHandoffSeed( + candidate: unknown, + evidenceRoot: string, +): HandoffSeedVerdict { + const kind = isPlainObject(candidate) ? candidate.kind : undefined; + if (kind === "issue") return validateIssueSeed(candidate, evidenceRoot); + if (kind === "hypothesis") return validateHypothesisSeed(candidate, evidenceRoot); + return { + ok: false, + issues: [ + { + path: "/kind", + message: `kind must be "issue" or "hypothesis" (got ${JSON.stringify(kind)})`, + }, + ], + }; +} + +// ─── Rendering templates (enforcement copies of handoff-seeds/*.template.md) ─ + +/** write-an-issue decision surface: frontmatter carries the mechanical fields, + * the body carries title (H1) and motivation (the Problem line). */ +export const ISSUE_SEED_TEMPLATE = `--- +kind: {{kind}} +suggested_repo: {{suggested_repo}} +suggested_tier: {{suggested_tier}} +evidence: +{{evidence}} +--- + + + +# {{title}} + +> [!IMPORTANT] +> **Problem** — {{motivation}} +> **Approach** — Typed handoff from a research campaign; file through the normal issue flow at the suggested tier. +> **Scope** — in: the seeded ask · out: filing agency (the receiving side files it) + +## Acceptance Criteria +- [ ] The filed issue carries the seed's evidence pointers, resolved at handoff time + +## Prior Art +- Evidence pointers ride the frontmatter \`evidence\` list; each resolved at validation time. + +## Source +- Handoff issue seed, rendered from the committed template. +`; + +/** Vault hypothesis-card convention: frontmatter (type, date, source, status, + * evidence, tags), body carries observation (H1) and the suggested experiment. */ +export const HYPOTHESIS_SEED_TEMPLATE = `--- +type: hypothesis +date: {{date}} +source: handoff-seed +status: open +evidence: +{{evidence}} +tags: [hypothesis, handoff] +--- + + + +# {{observation}} + +**Suggested experiment:** {{suggested_experiment}} +`; + +// ─── Render (template substitution, single pass) ───────────────────────────── + +function requireSingleLine(field: string, value: string): string { + if (/[\n\r]/.test(value)) { + throw new Error( + `handoff seed field "${field}" must be a single line to render into the committed template`, + ); + } + return value; +} + +function substitute(template: string, slots: Readonly>): string { + return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { + if (!(key in slots)) { + throw new Error(`template slot {{${key}}} has no value`); + } + return slots[key]; + }); +} + +/** YAML-emit one scalar (quoted iff the value requires it). */ +function yamlScalar(value: string): string { + const emitted = stringifyYaml(value); + return emitted.endsWith("\n") ? emitted.slice(0, -1) : emitted; +} + +/** YAML-emit a block list, indented two spaces under its frontmatter key. */ +function yamlList(values: readonly string[]): string { + return stringifyYaml([...values]) + .split("\n") + .filter((line) => line !== "") + .map((line) => ` ${line}`) + .join("\n"); +} + +function renderEvidence(evidence: readonly string[]): string { + if (evidence.length === 0) { + throw new Error("handoff seed must carry at least one evidence pointer to render"); + } + return yamlList(evidence.map((pointer) => requireSingleLine("evidence item", pointer))); +} + +export function renderIssueSeed(seed: IssueSeed, template: string = ISSUE_SEED_TEMPLATE): string { + return substitute(template, { + kind: "issue", + suggested_repo: yamlScalar(requireSingleLine("suggested_repo", seed.suggested_repo)), + suggested_tier: yamlScalar(seed.suggested_tier), + evidence: renderEvidence(seed.evidence), + title: requireSingleLine("title", seed.title), + motivation: requireSingleLine("motivation", seed.motivation), + }); +} + +export interface HypothesisCardOptions { + /** Card date (ISO YYYY-MM-DD); defaults to today. Not a seed field — the + * extractor never reads it, so it never affects the round-trip. */ + readonly date?: string; + readonly template?: string; +} + +export function renderHypothesisSeed( + seed: HypothesisSeed, + options: HypothesisCardOptions = {}, +): string { + const date = options.date ?? new Date().toISOString().slice(0, 10); + return substitute(options.template ?? HYPOTHESIS_SEED_TEMPLATE, { + date: yamlScalar(requireSingleLine("date", date)), + evidence: renderEvidence(seed.evidence), + observation: requireSingleLine("observation", seed.observation), + suggested_experiment: requireSingleLine( + "suggested_experiment", + seed.suggested_experiment, + ), + }); +} + +// ─── Extract (rendered artifact → seed) ────────────────────────────────────── + +function splitArtifact( + artifact: string, + expectedGuard: string, +): { frontmatter: Record; body: string } { + if (!artifact.startsWith("---\n")) { + throw new Error("handoff artifact must open with a YAML frontmatter block"); + } + const end = artifact.indexOf("\n---\n", 1); + if (end < 0) { + throw new Error("handoff artifact frontmatter is not terminated"); + } + let frontmatter: unknown; + try { + frontmatter = parseYaml(artifact.slice(4, end)); + } catch (error) { + throw new Error(`handoff artifact frontmatter is not valid YAML: ${(error as Error).message}`); + } + if (!isPlainObject(frontmatter)) { + throw new Error("handoff artifact frontmatter must be a mapping"); + } + const body = artifact.slice(end + 5); + if (!body.includes(expectedGuard)) { + throw new Error(`handoff artifact is missing its template guard (${expectedGuard})`); + } + return { frontmatter, body }; +} + +function firstH1(body: string): string { + const match = body.match(/^# (.+)$/m); + if (!match) { + throw new Error("handoff artifact body must carry the seed's statement as an H1"); + } + return match[1]; +} + +function frontmatterEvidence(frontmatter: Record): string[] { + const evidence = frontmatter.evidence; + if (!Array.isArray(evidence) || evidence.some((e) => typeof e !== "string")) { + throw new Error("handoff artifact frontmatter evidence must be a list of pointer strings"); + } + return evidence as string[]; +} + +export function extractIssueSeed(artifact: string): IssueSeed { + const { frontmatter, body } = splitArtifact(artifact, ""); + if (frontmatter.kind !== "issue") { + throw new Error( + `issue-seed artifact must carry kind: issue in frontmatter (got ${JSON.stringify(frontmatter.kind)})`, + ); + } + const problem = body.match(/^> \*\*Problem\*\* — (.*)$/m); + if (!problem) { + throw new Error("issue-seed artifact is missing the **Problem** line of its decision surface"); + } + return { + kind: "issue", + title: firstH1(body), + motivation: problem[1], + evidence: frontmatterEvidence(frontmatter), + suggested_repo: String(frontmatter.suggested_repo ?? ""), + suggested_tier: frontmatter.suggested_tier as SuggestedTier, + }; +} + +export function extractHypothesisSeed(artifact: string): HypothesisSeed { + const { frontmatter, body } = splitArtifact( + artifact, + "", + ); + if (frontmatter.type !== "hypothesis") { + throw new Error( + `hypothesis-seed artifact must carry type: hypothesis in frontmatter (got ${JSON.stringify(frontmatter.type)})`, + ); + } + const experiment = body.match(/^\*\*Suggested experiment:\*\* (.*)$/m); + if (!experiment) { + throw new Error("hypothesis-seed artifact is missing its **Suggested experiment:** line"); + } + return { + kind: "hypothesis", + observation: firstH1(body), + evidence: frontmatterEvidence(frontmatter), + suggested_experiment: experiment[1], + }; +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-masquerade-no-experiment.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-masquerade-no-experiment.json new file mode 100644 index 00000000..53a22f1e --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-masquerade-no-experiment.json @@ -0,0 +1,10 @@ +{ + "kind": "hypothesis", + "title": "Two-qubit gates need a from-scratch coupled model, not a stretched template", + "motivation": "Every failed two-qubit attempt reused a single-qubit template's shape; none built the coupling terms explicitly, so the failures say nothing about two-qubit feasibility.", + "evidence": [ + "insights/insight-20260822-063402-noise-normalized-detectors.md" + ], + "suggested_repo": "armonia", + "suggested_tier": "standard" +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-missing-evidence.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-missing-evidence.json new file mode 100644 index 00000000..391eae47 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-missing-evidence.json @@ -0,0 +1,5 @@ +{ + "kind": "hypothesis", + "observation": "Rydberg single-qubit gates saturate near 10 ns under global drive", + "suggested_experiment": "Min-time sweep the banked global-drive X pulse from 12 ns down to 8 ns and locate the fidelity wall." +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-dead-evidence-pointer.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-dead-evidence-pointer.json new file mode 100644 index 00000000..e83c8507 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-dead-evidence-pointer.json @@ -0,0 +1,11 @@ +{ + "kind": "issue", + "title": "Cat-state solves stall without a Fock-cutoff sufficiency guard", + "motivation": "Six failed cavity preps share one signature: a Fock cutoff too small for the target state's transients, silently truncating the state and corrupting the fidelity; the authoring flow never warns.", + "evidence": [ + "insights/insight-20260822-063401-drift-detector-threshold.md", + "insights/insight-19700101-000000-never-recorded.md" + ], + "suggested_repo": "amicode", + "suggested_tier": "chore" +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-masquerade-no-repo.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-masquerade-no-repo.json new file mode 100644 index 00000000..e28a43ea --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-masquerade-no-repo.json @@ -0,0 +1,8 @@ +{ + "kind": "issue", + "observation": "Warm-starting from prior pulses failed consistently across the July batch", + "evidence": [ + "experiments/experiment-20260821-120000-blockade-sector-rank.md" + ], + "suggested_experiment": "Retry the warm-start batch with the trajectory loader from the newer package pin and compare first-iteration objective values." +} diff --git a/packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260821-120000-blockade-sector-rank.md b/packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260821-120000-blockade-sector-rank.md new file mode 100644 index 00000000..af04f04c --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260821-120000-blockade-sector-rank.md @@ -0,0 +1,19 @@ +--- +type: experiment +date: 2026-08-21 +task_type: gate_synthesis +session_id: null +platform: rydberg +gate: gate/XX +fidelity: 0.998 +duration_us: 0.012 +status: completed +tags: [experiment, rydberg, blockade, hessian] +--- + +# Sector-rank growth under finite blockade + +Swept the symmetric blockaded C_kZ family from deep toward moderate blockade on +the emulator twin. Hessian rank grows with atom count faster than the perfect- +blockade 3n line at every non-deep setting, with no super-polynomial blowup up +to the largest n attempted. diff --git a/packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260822-193053-rwa-breakdown.md b/packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260822-193053-rwa-breakdown.md new file mode 100644 index 00000000..d6c2e18d --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/root/experiments/experiment-20260822-193053-rwa-breakdown.md @@ -0,0 +1,18 @@ +--- +type: experiment +date: 2026-08-22 +task_type: state_prep +session_id: null +platform: bosonic +gate: null +fidelity: 0.981 +duration_us: 4.0 +status: completed +tags: [experiment, cavity, cat-state, rwa] +--- + +# Cat-state solve leakage vs spline bending energy + +Replayed the α=2 cat-state preps that leaked: the ones that leaked into the +|α|-band all carried du-knot oscillations, and the bending energy of the pulse +separates leaky from clean solves cleanly. Knot count alone did not. diff --git a/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063401-drift-detector-threshold.md b/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063401-drift-detector-threshold.md new file mode 100644 index 00000000..29be854d --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063401-drift-detector-threshold.md @@ -0,0 +1,16 @@ +--- +type: insight +date: 2026-08-22 +source: experiment +evidence: [] +confidence: medium +tags: [insight, detector, stagnation] +--- + +# Detector thresholds want noise normalization + +The stagnation detector fired on three runs that were still improving: the raw +fidelity-delta threshold sits below the run's own noise floor, so ordinary +iteration noise reads as a plateau. Normalizing the threshold by the rolling +fidelity variance separated the true plateaus from the noise in every replayed +run. diff --git a/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063402-noise-normalized-detectors.md b/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063402-noise-normalized-detectors.md new file mode 100644 index 00000000..f659035f --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063402-noise-normalized-detectors.md @@ -0,0 +1,16 @@ +--- +type: insight +date: 2026-08-22 +source: experiment +evidence: [] +confidence: high +tags: [insight, detector, multistart] +--- + +# Onset and inversion detectors compose; thresholds do not transfer + +The onset detector (curvature of the fidelity series) and the inversion +detector (sign flip of the trend) compose cleanly, but a threshold tuned on the +onset detector misfires when reused for the inversion detector — each wants its +own noise-normalized calibration. The multistart cascade dispatched twice on +runs that had not in fact stagnated. diff --git a/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063403-bending-energy-rwa.md b/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063403-bending-energy-rwa.md new file mode 100644 index 00000000..f21509e3 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/root/insights/insight-20260822-063403-bending-energy-rwa.md @@ -0,0 +1,16 @@ +--- +type: insight +date: 2026-08-22 +source: experiment +evidence: [] +confidence: high +tags: [insight, cavity, rwa, parameterization] +--- + +# Bending energy, not knot count, predicts RWA breakdown + +Across the replayed cat-state batch, the rotating-wave approximation holds +until the pulse's bending energy crosses a threshold; du-knot oscillations ride +the same signal. Knot count and crest sharpness predicted nothing on their own. +The missing piece in the failing solves was a level: adding the next transmon +level to the model is what removes the leakage. diff --git a/packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-blockade-rank.json b/packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-blockade-rank.json new file mode 100644 index 00000000..98ec3538 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-blockade-rank.json @@ -0,0 +1,8 @@ +{ + "kind": "hypothesis", + "observation": "Finite blockade inflates the low-rank-Hessian sector count above 3n, still polynomially", + "evidence": [ + "experiments/experiment-20260821-120000-blockade-sector-rank.md" + ], + "suggested_experiment": "Sweep V_nn/Omega from deep toward moderate blockade on the symmetric blockaded C_kZ family and extract the rank growth exponent versus n at each setting." +} diff --git a/packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-implicit-drag.json b/packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-implicit-drag.json new file mode 100644 index 00000000..e1934aac --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/valid/hypothesis-implicit-drag.json @@ -0,0 +1,9 @@ +{ + "kind": "hypothesis", + "observation": "Adding the fourth transmon level (implicit DRAG) removes the |α|-band leakage that bending energy predicts", + "evidence": [ + "experiments/experiment-20260822-193053-rwa-breakdown.md", + "insights/insight-20260822-063403-bending-energy-rwa.md" + ], + "suggested_experiment": "Re-solve the same leaking cat-state splines at levels=4 and diff the leakage spectrum against the levels=3 baseline." +} diff --git a/packages/extension/test/fixtures/handoff-seeds/valid/issue-detector-thresholds.json b/packages/extension/test/fixtures/handoff-seeds/valid/issue-detector-thresholds.json new file mode 100644 index 00000000..48dd6ad6 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/valid/issue-detector-thresholds.json @@ -0,0 +1,11 @@ +{ + "kind": "issue", + "title": "Stagnation detector fires on un-normalized thresholds — calibrate per detector, per run", + "motivation": "The stagnation cascade dispatched multistarts on runs that were still improving: the plateau threshold sits below the run's noise floor and one calibration is shared across detectors that each want their own.", + "evidence": [ + "insights/insight-20260822-063401-drift-detector-threshold.md", + "insights/insight-20260822-063402-noise-normalized-detectors.md" + ], + "suggested_repo": "amicode", + "suggested_tier": "standard" +} diff --git a/packages/extension/test/fixtures/handoff-seeds/valid/issue-rwa-bending-energy.json b/packages/extension/test/fixtures/handoff-seeds/valid/issue-rwa-bending-energy.json new file mode 100644 index 00000000..59962b4a --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/valid/issue-rwa-bending-energy.json @@ -0,0 +1,11 @@ +{ + "kind": "issue", + "title": "Bending energy, not knot count, predicts RWA breakdown — add a pre-solve spline lint", + "motivation": "Cat-state solves leak into the |α|-band exactly when pulse bending energy crosses a threshold, and the wasted solves are hours long; a pre-solve lint on bending energy would refuse under-resolved splines before the burn.", + "evidence": [ + "experiments/experiment-20260822-193053-rwa-breakdown.md", + "insights/insight-20260822-063403-bending-energy-rwa.md" + ], + "suggested_repo": "amicode", + "suggested_tier": "PRD" +} diff --git a/packages/extension/test/handoff_seeds.test.ts b/packages/extension/test/handoff_seeds.test.ts new file mode 100644 index 00000000..9e91f0f1 --- /dev/null +++ b/packages/extension/test/handoff_seeds.test.ts @@ -0,0 +1,250 @@ +// Handoff seeds (#499) — spec-20260822 D3 + Measurement Protocol `handoff_roundtrip_pass`. +// +// Cross-campaign handoffs are typed artifacts: an ISSUE seed (research → dev, +// rendered to the write-an-issue decision surface) and a HYPOTHESIS seed +// (dev → research, rendered to a vault hypothesis card). The contract under +// test: every valid fixture parses against its committed schema AND round-trips +// — seed → committed-template render → re-extract → field-equal over the full +// declared field set. Adversarial fixtures are REFUSED, never warned, with the +// violated schema path named: dead evidence pointers, the kind/field-set +// masquerade, missing required fields. +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + validateHandoffSeed, + renderIssueSeed, + extractIssueSeed, + renderHypothesisSeed, + extractHypothesisSeed, + ISSUE_SEED_SCHEMA, + HYPOTHESIS_SEED_SCHEMA, + ISSUE_SEED_TEMPLATE, + HYPOTHESIS_SEED_TEMPLATE, + type IssueSeed, + type HypothesisSeed, +} from "../src/handoff_seeds"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixtureDir = path.join(here, "fixtures/handoff-seeds"); +const evidenceRoot = path.join(fixtureDir, "root"); +const contractDir = path.resolve(here, "../handoff-seeds"); + +const read = (absolute: string): string => readFileSync(absolute, "utf8"); +const readJson = (rel: string): unknown => + JSON.parse(readFileSync(path.join(fixtureDir, rel), "utf8")); + +const jsonFiles = (rel: string): string[] => + readdirSync(path.join(fixtureDir, rel)) + .filter((name) => name.endsWith(".json")) + .sort(); + +// A fixed card date so hypothesis renders are byte-deterministic in tests; +// the date is card furniture, never a seed field, so it cannot skew equality. +const CARD_DATE = "2026-08-23"; + +describe("handoff seeds (#499): valid issue seeds parse + round-trip", () => { + for (const name of jsonFiles("valid").filter((n) => n.startsWith("issue-"))) { + it(`issue seed ${name} validates and round-trips field-equal`, () => { + const seed = readJson(`valid/${name}`) as IssueSeed; + + const verdict = validateHandoffSeed(seed, evidenceRoot); + expect(verdict.issues).toEqual([]); + expect(verdict.ok).toBe(true); + + const extracted = extractIssueSeed(renderIssueSeed(seed)); + expect(extracted).toEqual(seed); // full declared field set, exact values + expect(Object.keys(extracted).sort()).toEqual([ + "evidence", + "kind", + "motivation", + "suggested_repo", + "suggested_tier", + "title", + ]); + + // the re-extracted seed is itself a valid seed — the loop closes + expect(validateHandoffSeed(extracted, evidenceRoot).ok).toBe(true); + }); + } +}); + +describe("handoff seeds (#499): valid hypothesis seeds parse + round-trip", () => { + for (const name of jsonFiles("valid").filter((n) => n.startsWith("hypothesis-"))) { + it(`hypothesis seed ${name} validates and round-trips field-equal`, () => { + const seed = readJson(`valid/${name}`) as HypothesisSeed; + + const verdict = validateHandoffSeed(seed, evidenceRoot); + expect(verdict.issues).toEqual([]); + expect(verdict.ok).toBe(true); + + const extracted = extractHypothesisSeed( + renderHypothesisSeed(seed, { date: CARD_DATE }), + ); + expect(extracted).toEqual(seed); // full declared field set, exact values + expect(Object.keys(extracted).sort()).toEqual([ + "evidence", + "kind", + "observation", + "suggested_experiment", + ]); + + expect(validateHandoffSeed(extracted, evidenceRoot).ok).toBe(true); + }); + } +}); + +describe("handoff seeds (#499): adversarial refusals name the violated path", () => { + const adversarial = (name: string): unknown => readJson(`adversarial/${name}`); + const pathsOf = (name: string): string[] => { + const verdict = validateHandoffSeed(adversarial(name), evidenceRoot); + expect(verdict.ok).toBe(false); // refused, never warned + expect(verdict.seed).toBeUndefined(); + expect(verdict.issues.length).toBeGreaterThan(0); + return verdict.issues.map((issue) => issue.path); + }; + + it("dead evidence pointer is refused, naming the pointer's index", () => { + expect(pathsOf("issue-dead-evidence-pointer.json")).toContain("/evidence/1"); + }); + + it("masquerade: kind issue without suggested_repo is refused, naming the field", () => { + // the masquerade is MECHANICAL: kind "issue" is itself well-formed, but the + // field set is a hypothesis's — the kind/field-set disagreement surfaces as + // the missing issue-required fields plus foreign hypothesis fields. + const paths = pathsOf("issue-masquerade-no-repo.json"); + expect(paths).toContain("/suggested_repo"); + expect(paths).toContain("/title"); // issue-required fields the shape lacks + expect(paths).toContain("/suggested_experiment"); // foreign field, refused + }); + + it("masquerade: kind hypothesis without suggested_experiment is refused, naming the field", () => { + const paths = pathsOf("hypothesis-masquerade-no-experiment.json"); + expect(paths).toContain("/suggested_experiment"); + expect(paths).toContain("/observation"); // hypothesis-required, absent + expect(paths).toContain("/suggested_repo"); // foreign field, refused + }); + + it("missing required field is refused, naming the field", () => { + expect(pathsOf("hypothesis-missing-evidence.json")).toContain("/evidence"); + }); + + it("a seed whose kind is neither issue nor hypothesis is refused at /kind", () => { + const verdict = validateHandoffSeed({ kind: "note", evidence: [] }, evidenceRoot); + expect(verdict.ok).toBe(false); + expect(verdict.issues.map((issue) => issue.path)).toEqual(["/kind"]); + }); +}); + +describe("handoff seeds (#499): the committed contract is the enforcement copy", () => { + it("handoff-seeds/issue-seed.schema.json equals the embedded issue schema", () => { + expect(JSON.parse(read(path.join(contractDir, "issue-seed.schema.json")))).toEqual( + ISSUE_SEED_SCHEMA, + ); + }); + + it("handoff-seeds/hypothesis-seed.schema.json equals the embedded hypothesis schema", () => { + expect(JSON.parse(read(path.join(contractDir, "hypothesis-seed.schema.json")))).toEqual( + HYPOTHESIS_SEED_SCHEMA, + ); + }); + + it("handoff-seeds/issue-seed.template.md is the render source for issue seeds", () => { + const seed = readJson("valid/issue-detector-thresholds.json") as IssueSeed; + const fileTemplate = read(path.join(contractDir, "issue-seed.template.md")); + expect(renderIssueSeed(seed, fileTemplate)).toBe(renderIssueSeed(seed)); + }); + + it("handoff-seeds/hypothesis-seed.template.md is the render source for hypothesis seeds", () => { + const seed = readJson("valid/hypothesis-blockade-rank.json") as HypothesisSeed; + const fileTemplate = read(path.join(contractDir, "hypothesis-seed.template.md")); + expect(renderHypothesisSeed(seed, { date: CARD_DATE, template: fileTemplate })).toBe( + renderHypothesisSeed(seed, { date: CARD_DATE }), + ); + }); + + it("the embedded templates match the committed files byte-for-byte", () => { + expect(read(path.join(contractDir, "issue-seed.template.md"))).toBe(ISSUE_SEED_TEMPLATE); + expect(read(path.join(contractDir, "hypothesis-seed.template.md"))).toBe( + HYPOTHESIS_SEED_TEMPLATE, + ); + }); +}); + +describe("handoff seeds (#499): corpus floors (Measurement Protocol)", () => { + it("≥ 4 valid fixtures, ≥ 2 per seed kind", () => { + const valid = jsonFiles("valid"); + expect(valid.filter((n) => n.startsWith("issue-")).length).toBeGreaterThanOrEqual(2); + expect(valid.filter((n) => n.startsWith("hypothesis-")).length).toBeGreaterThanOrEqual(2); + expect(valid.length).toBeGreaterThanOrEqual(4); + }); + + it("≥ 3 adversarial fixtures", () => { + expect(jsonFiles("adversarial").length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("handoff seeds (#499): zero blocklisted strings in committed slice files", () => { + // The proper-noun blocklist (spec D4), assembled from fragments so that THIS + // file never contains the strings contiguously — it greps itself too. + const BLOCKLIST = [ + "tel" + "aio", + "Piccol" + "issimo", + "Alt" + "issimo", + "harmo" + "niqs", + "spar" + "tito", + ]; + + const walk = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const p = path.join(dir, entry.name); + return entry.isDirectory() ? walk(p) : [p]; + }); + + const sliceFiles = [ + path.join(contractDir, "issue-seed.schema.json"), + path.join(contractDir, "hypothesis-seed.schema.json"), + path.join(contractDir, "issue-seed.template.md"), + path.join(contractDir, "hypothesis-seed.template.md"), + path.resolve(here, "../src/handoff_seeds.ts"), + path.join(here, "handoff_seeds.test.ts"), + ...walk(fixtureDir), + ]; + + it("no committed slice file carries a blocklisted proper noun", () => { + expect(sliceFiles.length).toBeGreaterThan(6); + const violations: string[] = []; + for (const file of sliceFiles) { + const content = read(file); + for (const banned of BLOCKLIST) { + if (content.includes(banned)) { + violations.push(`${path.relative(here, file)}: contains a blocklisted proper noun`); + } + } + } + expect(violations).toEqual([]); + }); +}); + +describe("handoff seeds (#499): render targets", () => { + const issueSeed = readJson("valid/issue-detector-thresholds.json") as IssueSeed; + const hypothesisSeed = readJson("valid/hypothesis-blockade-rank.json") as HypothesisSeed; + + it("issue-seed render IS the write-an-issue decision surface", () => { + const artifact = renderIssueSeed(issueSeed); + expect(artifact).toContain("> [!IMPORTANT]"); + expect(artifact).toContain("**Problem** — "); + expect(artifact).toContain("## Acceptance Criteria"); + expect(artifact).toContain("## Prior Art"); + }); + + it("hypothesis-seed render IS the vault hypothesis card", () => { + const artifact = renderHypothesisSeed(hypothesisSeed, { date: CARD_DATE }); + expect(artifact).toContain("type: hypothesis"); + expect(artifact).toContain(`date: ${CARD_DATE}`); + expect(artifact).toContain("source: handoff-seed"); + expect(artifact).toContain("status: open"); + expect(artifact).toContain("tags: [hypothesis, handoff]"); + }); +}); From 2a2e2835b5340cefb48f16444e41559ea7ed92e9 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 23 Aug 2026 08:14:11 -0400 Subject: [PATCH 2/2] =?UTF-8?q?test(handoff):=20reviewer=20adversarial=20v?= =?UTF-8?q?ariants=20=E2=80=94=20authorship=20gate=20discharged=20(#519)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handoff-seeds/hypothesis-seed.schema.json | 1 + .../handoff-seeds/issue-seed.schema.json | 1 + packages/extension/src/handoff_seeds.ts | 29 +++++++++---- .../fixtures/handoff-seeds/ATTESTATION.md | 1 + .../hypothesis-directory-evidence.json | 9 ++++ .../hypothesis-duplicate-evidence.json | 9 ++++ .../hypothesis-empty-experiment.json | 8 ++++ .../issue-absolute-path-evidence.json | 11 +++++ .../adversarial/issue-foreign-field.json | 11 +++++ .../adversarial/issue-root-self-evidence.json | 10 +++++ .../adversarial/issue-traversal-wikilink.json | 10 +++++ packages/extension/test/handoff_seeds.test.ts | 42 ++++++++++++++++++- 12 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 packages/extension/test/fixtures/handoff-seeds/ATTESTATION.md create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-directory-evidence.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-duplicate-evidence.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-empty-experiment.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/issue-absolute-path-evidence.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/issue-foreign-field.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/issue-root-self-evidence.json create mode 100644 packages/extension/test/fixtures/handoff-seeds/adversarial/issue-traversal-wikilink.json diff --git a/packages/extension/handoff-seeds/hypothesis-seed.schema.json b/packages/extension/handoff-seeds/hypothesis-seed.schema.json index 07e9e470..ca23b777 100644 --- a/packages/extension/handoff-seeds/hypothesis-seed.schema.json +++ b/packages/extension/handoff-seeds/hypothesis-seed.schema.json @@ -13,6 +13,7 @@ "evidence": { "type": "array", "minItems": 1, + "uniqueItems": true, "items": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" } }, "suggested_experiment": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" } diff --git a/packages/extension/handoff-seeds/issue-seed.schema.json b/packages/extension/handoff-seeds/issue-seed.schema.json index 1b6430e6..d47fdd43 100644 --- a/packages/extension/handoff-seeds/issue-seed.schema.json +++ b/packages/extension/handoff-seeds/issue-seed.schema.json @@ -14,6 +14,7 @@ "evidence": { "type": "array", "minItems": 1, + "uniqueItems": true, "items": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" } }, "suggested_repo": { "type": "string", "minLength": 1, "pattern": "^[^\\n\\r]*$" }, diff --git a/packages/extension/src/handoff_seeds.ts b/packages/extension/src/handoff_seeds.ts index e84222af..a5af72af 100644 --- a/packages/extension/src/handoff_seeds.ts +++ b/packages/extension/src/handoff_seeds.ts @@ -10,8 +10,9 @@ // bound: a fixture whose kind and field set disagree is refused (the // masquerade test). // 2. EVIDENCE — pointers are vault-relative paths that must RESOLVE at -// validation time against a root; a seed citing a nonexistent artifact is -// refused, not warned. +// validation time against a root; a seed citing a nonexistent artifact — +// or a directory, or the validation root itself, or the same artifact +// twice — is refused, not warned. // 3. ROUND-TRIP — seed → committed-template render → re-extract → // field-equal over the full declared field set. Rendering is template // substitution (single pass, no re-scanning), so inserted values are @@ -20,7 +21,7 @@ // Filing agency stays with the receiving side (protocol discipline): nothing // here auto-files an issue or writes a card. -import { existsSync } from "node:fs"; +import { statSync } from "node:fs"; import { resolve as resolvePath, sep } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; @@ -87,6 +88,7 @@ export const ISSUE_SEED_SCHEMA: JsonSchema = { evidence: { type: "array", minItems: 1, + uniqueItems: true, items: { type: "string", minLength: 1, pattern: SINGLE_LINE }, }, suggested_repo: { type: "string", minLength: 1, pattern: SINGLE_LINE }, @@ -113,6 +115,7 @@ export const HYPOTHESIS_SEED_SCHEMA: JsonSchema = { evidence: { type: "array", minItems: 1, + uniqueItems: true, items: { type: "string", minLength: 1, pattern: SINGLE_LINE }, }, suggested_experiment: { type: "string", minLength: 1, pattern: SINGLE_LINE }, @@ -155,7 +158,7 @@ function describeType(value: unknown): string { } /** Validate `value` against a JSON Schema (subset: type, const, enum, - * minLength, pattern, minItems, items, properties, required, + * minLength, pattern, minItems, uniqueItems, items, properties, required, * additionalProperties). Returns every violation with its JSON-Pointer path. */ export function validateAgainstSchema( value: unknown, @@ -196,6 +199,16 @@ export function validateAgainstSchema( if (typeof minItems === "number" && value.length < minItems) { issues.push({ path: here, message: `fewer than minItems ${minItems}` }); } + if (schema.uniqueItems === true) { + // SameValueZero over the items — our schemas only use uniqueItems on + // arrays of scalars, where reference identity never comes into play. + if (new Set(value).size !== value.length) { + issues.push({ + path: here, + message: "array items are not unique (uniqueItems); evidence pointers are a citation set", + }); + } + } const items = schema.items; if (isPlainObject(items)) { value.forEach((item, index) => { @@ -238,15 +251,17 @@ export function validateAgainstSchema( // ─── Validation (schema + evidence-pointer resolution) ─────────────────────── -/** A pointer resolves iff it names an existing path under `root` (wiki-link - * brackets tolerated, traversal and absolute paths refused). */ +/** A pointer resolves iff it names an existing REGULAR FILE under `root` + * (wiki-link brackets tolerated; traversal, absolute paths, directories — + * including the validation root itself — refused). Evidence cites artifacts, + * never containers. */ function pointerResolves(root: string, pointer: string): boolean { const bare = pointer.startsWith("[[") && pointer.endsWith("]]") ? pointer.slice(2, -2) : pointer; const resolved = resolvePath(root, bare); const rootAbs = resolvePath(root); if (resolved !== rootAbs && !resolved.startsWith(rootAbs + sep)) return false; - return existsSync(resolved); + return statSync(resolved, { throwIfNoEntry: false })?.isFile() === true; } function checkEvidencePointers( diff --git a/packages/extension/test/fixtures/handoff-seeds/ATTESTATION.md b/packages/extension/test/fixtures/handoff-seeds/ATTESTATION.md new file mode 100644 index 00000000..e860d1b1 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/ATTESTATION.md @@ -0,0 +1 @@ +reviewer pass 2026-08-23: 7 adversarial variants added by an independent reviewer agent (wiki-link traversal, absolute+bare traversal, empty string field, cross-kind contamination on a complete seed, directory pointers, root-self pointer, duplicate evidence); findings: evidence pointers naming directories — including the validation root "." — were ACCEPTED (fixed: pointerResolves now requires a regular file), duplicate evidence pointers were ACCEPTED (fixed: uniqueItems on the evidence schemas, both committed and embedded copies); in-root traversal that lands on a real artifact remains accepted by design (it resolves to a genuine file; canonicality is a lint concern, not a gate). diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-directory-evidence.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-directory-evidence.json new file mode 100644 index 00000000..5d36fa65 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-directory-evidence.json @@ -0,0 +1,9 @@ +{ + "kind": "hypothesis", + "observation": "Evidence pointers naming a directory — bracketed or trailing-slash — cite a container, not an artifact", + "evidence": [ + "[[experiments]]", + "insights/" + ], + "suggested_experiment": "Confirm the validator refuses directory pointers in both spellings before any consumer counts a whole folder as one citation." +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-duplicate-evidence.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-duplicate-evidence.json new file mode 100644 index 00000000..176c63e1 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-duplicate-evidence.json @@ -0,0 +1,9 @@ +{ + "kind": "hypothesis", + "observation": "Duplicate evidence pointers inflate a seed's apparent evidence breadth without adding a citation", + "evidence": [ + "experiments/experiment-20260822-193053-rwa-breakdown.md", + "experiments/experiment-20260822-193053-rwa-breakdown.md" + ], + "suggested_experiment": "Assert the evidence list behaves as a citation set: the same artifact twice is one refusal at /evidence, not two resolved pointers." +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-empty-experiment.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-empty-experiment.json new file mode 100644 index 00000000..9ca3d33f --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/hypothesis-empty-experiment.json @@ -0,0 +1,8 @@ +{ + "kind": "hypothesis", + "observation": "An empty suggested_experiment field renders a hypothesis card whose experiment line is blank", + "evidence": [ + "experiments/experiment-20260822-193053-rwa-breakdown.md" + ], + "suggested_experiment": "" +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-absolute-path-evidence.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-absolute-path-evidence.json new file mode 100644 index 00000000..e1359345 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-absolute-path-evidence.json @@ -0,0 +1,11 @@ +{ + "kind": "issue", + "title": "Absolute-path and bare-traversal evidence pointers must not resolve against the vault root", + "motivation": "A seed whose evidence cites machine-absolute paths or pointers that walk out of the vault is claiming artifacts it does not have; both must be refused at the pointer's index, not quietly re-rooted.", + "evidence": [ + "/etc/hosts", + "../outside-vault/experiment-20260822-193053-rwa-breakdown.md" + ], + "suggested_repo": "amicode", + "suggested_tier": "standard" +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-foreign-field.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-foreign-field.json new file mode 100644 index 00000000..9967d59d --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-foreign-field.json @@ -0,0 +1,11 @@ +{ + "kind": "issue", + "title": "An otherwise-complete issue seed carrying one foreign hypothesis field is a cross-kind contamination", + "motivation": "Every issue-required field is present and every evidence pointer resolves, so a field-set check that only looks for MISSING fields would wave this through; the foreign suggested_experiment is the tell that the emitter mixed two seed kinds into one payload.", + "evidence": [ + "insights/insight-20260822-063401-drift-detector-threshold.md" + ], + "suggested_repo": "amicode", + "suggested_tier": "standard", + "suggested_experiment": "Re-run the July warm-start batch with the newer loader and diff first-iteration objectives." +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-root-self-evidence.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-root-self-evidence.json new file mode 100644 index 00000000..ef931ada --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-root-self-evidence.json @@ -0,0 +1,10 @@ +{ + "kind": "issue", + "title": "An evidence pointer naming the validation root itself cites the entire vault as one artifact", + "motivation": "The degenerate pointer \".\" resolves to the validation root, which always exists; a seed citing it claims everything and nothing at once, and must be refused rather than treated as well-evidenced.", + "evidence": [ + "." + ], + "suggested_repo": "amicode", + "suggested_tier": "chore" +} diff --git a/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-traversal-wikilink.json b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-traversal-wikilink.json new file mode 100644 index 00000000..69a20426 --- /dev/null +++ b/packages/extension/test/fixtures/handoff-seeds/adversarial/issue-traversal-wikilink.json @@ -0,0 +1,10 @@ +{ + "kind": "issue", + "title": "Evidence pointers wrapped in wiki-link brackets slip traversal past naive path joins", + "motivation": "A seed emitter naively wraps whatever the researcher typed in [[...]] brackets; an upstream pointer like a path to the machine's password file must not become citable evidence just because the validator strips the brackets before resolving.", + "evidence": [ + "[[../../etc/passwd]]" + ], + "suggested_repo": "amicode", + "suggested_tier": "chore" +} diff --git a/packages/extension/test/handoff_seeds.test.ts b/packages/extension/test/handoff_seeds.test.ts index 9e91f0f1..28882670 100644 --- a/packages/extension/test/handoff_seeds.test.ts +++ b/packages/extension/test/handoff_seeds.test.ts @@ -7,7 +7,9 @@ // — seed → committed-template render → re-extract → field-equal over the full // declared field set. Adversarial fixtures are REFUSED, never warned, with the // violated schema path named: dead evidence pointers, the kind/field-set -// masquerade, missing required fields. +// masquerade, missing required fields, and (reviewer pass) traversal and +// absolute pointers, directory/root pointers, duplicate citations, +// cross-kind contamination on an otherwise-complete seed, and empty fields. import { describe, it, expect } from "vitest"; import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; @@ -130,6 +132,44 @@ describe("handoff seeds (#499): adversarial refusals name the violated path", () expect(pathsOf("hypothesis-missing-evidence.json")).toContain("/evidence"); }); + // ── reviewer pass 2026-08-23: adversarial variants born from live probing ── + + it("wiki-link traversal escaping the root is refused at the pointer's index", () => { + expect(pathsOf("issue-traversal-wikilink.json")).toContain("/evidence/0"); + }); + + it("absolute-path and bare-traversal evidence pointers are refused, each at its index", () => { + const paths = pathsOf("issue-absolute-path-evidence.json"); + expect(paths).toContain("/evidence/0"); + expect(paths).toContain("/evidence/1"); + }); + + it("an empty string field is refused, naming the field (not just missing ones)", () => { + expect(pathsOf("hypothesis-empty-experiment.json")).toContain("/suggested_experiment"); + }); + + it("an otherwise-COMPLETE issue seed carrying a foreign hypothesis field is refused (cross-kind contamination)", () => { + // unlike the masquerade fixtures, every issue-required field is present + // here — the ONLY tell is the foreign field, so this pins + // additionalProperties as an independent refusal reason. + expect(pathsOf("issue-foreign-field.json")).toContain("/suggested_experiment"); + }); + + it("evidence pointers naming directories are refused — evidence cites artifacts, not containers", () => { + // both spellings: wiki-link bracketed and trailing-slash + const paths = pathsOf("hypothesis-directory-evidence.json"); + expect(paths).toContain("/evidence/0"); + expect(paths).toContain("/evidence/1"); + }); + + it("an evidence pointer naming the validation root itself is refused", () => { + expect(pathsOf("issue-root-self-evidence.json")).toContain("/evidence/0"); + }); + + it("duplicate evidence pointers are refused at /evidence (the list is a citation set)", () => { + expect(pathsOf("hypothesis-duplicate-evidence.json")).toContain("/evidence"); + }); + it("a seed whose kind is neither issue nor hypothesis is refused at /kind", () => { const verdict = validateHandoffSeed({ kind: "note", evidence: [] }, evidenceRoot); expect(verdict.ok).toBe(false);