From e7e63b742c290c391c30e0efa3f62ebc66cd4fa9 Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Wed, 5 Aug 2026 16:33:53 +0800 Subject: [PATCH] refactor(evidence): ontology-declared source roles replace named flags GraphNodeMetadata's intentMissing/intentConflict/implementationMissing collapse into one role-agnostic missingRoles[]. validateEvidence checks presence only, blind to the role vocabulary. OntologyTypeValidator now also rejects a missingRoles entry the active ontology does not declare, the same allow-list check it runs for node and edge types. Neutralize the generic-mechanism fixtures (source, ontology, ontologies tests) to a test vocabulary. Drop the batch-plan v1 migration shim, the last intent/code literal in src; local workspace data was migrated in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/skills/shared/drift-detection.md | 4 ++-- .../core/skills/shared/proposal-format.md | 5 ++--- packages/core/skills/shared/validators.md | 7 +++--- .../validation/OntologyTypeValidator.ts | 17 ++++++++++++-- .../src/domain/validation/validateEvidence.ts | 16 +++++++------- .../HITLServiceHistoryHooks.test.ts | 3 ++- .../validation/OntologyTypeValidator.test.ts | 22 ++++++++++++++++++- .../validation/validateEvidence.test.ts | 18 +++++++-------- packages/ontology-ddd/skills/extract/SKILL.md | 8 +++---- packages/schema/src/model.ts | 8 ++++--- packages/schema/src/proposal-preview.ts | 14 +++++++++--- packages/schema/test/ontology.test.ts | 6 ++--- packages/schema/test/source.test.ts | 22 +++++++++---------- .../batch/FsBatchPlanRepository.ts | 21 +++--------------- .../batch/FsBatchPlanRepository.test.ts | 16 +------------- packages/server/test/integration/e2e.test.ts | 10 ++++----- .../test/integration/historyHooks.test.ts | 6 ++--- .../test/integration/historyRoutes.test.ts | 2 +- packages/server/test/routes.test.ts | 5 +++-- .../server/test/routes/ontologies.test.ts | 12 +++++----- packages/server/test/routes/showAll.test.ts | 2 +- .../test/routes/skillInputOptions.test.ts | 6 ++--- .../src/components/graph/NodeDetailPanel.tsx | 8 ++----- packages/test-utils/src/proposal.ts | 9 ++++---- 24 files changed, 129 insertions(+), 118 deletions(-) diff --git a/packages/core/skills/shared/drift-detection.md b/packages/core/skills/shared/drift-detection.md index 2b625103..fb95b671 100644 --- a/packages/core/skills/shared/drift-detection.md +++ b/packages/core/skills/shared/drift-detection.md @@ -48,7 +48,7 @@ Don't raise a `DriftIssue` for: - Style differences (camelCase vs snake_case, English vs Chinese phrasing). Names mean the same thing. - High-level intent vs low-level implementation detail (intent says "compute total price", code has 12 lines of arithmetic; that's expected, not drift). -- Code-only or intent-only existence at the *whole-node* level: that's already covered by `metadata.intentMissing` / `metadata.implementationMissing` flags on the node. Use `DriftIssue` for field-level drift on a shared concept. +- A role missing at the *whole-node* level (only some roles have evidence so far): that's already covered by `metadata.missingRoles` on the node. Use `DriftIssue` for field-level drift on a shared concept. - Vague suspicions ("I think these might differ but couldn't verify"). Either confirm with a specific cite or skip. Drift is structured evidence, not impressions. If the disagreement makes you unsure which concept these even *are* (two different `cancelOrder` candidates? same? distinct?), you don't have field-level drift; you have an identity question. Emit a `ClarifyTicket`, not a `DriftIssue`. @@ -139,5 +139,5 @@ Before attaching a `DriftIssue`: - [ ] Two specific source citations (file + line / anchor) - [ ] Description names both sides and the impact in one sentence - [ ] Severity matches the contradiction-vs-gap distinction above -- [ ] Not duplicating an `intentMissing` / `implementationMissing` flag at the whole-node level +- [ ] Not duplicating a `missingRoles` entry at the whole-node level - [ ] If multiple dimensions disagree, one `DriftIssue` per dimension diff --git a/packages/core/skills/shared/proposal-format.md b/packages/core/skills/shared/proposal-format.md index f43981ed..d8f9363b 100644 --- a/packages/core/skills/shared/proposal-format.md +++ b/packages/core/skills/shared/proposal-format.md @@ -43,14 +43,13 @@ Each entry in `operations[]` is one of: "sourceReferences": [ { "sourceId": "src-intent", "location": { "uri": "...", "anchor": "..." } } ], - "intentMissing": false, // optional; true means "code exists, no spec yet" - "implementationMissing": false, // optional; true means "spec exists, no code yet" + "missingRoles": [], // optional list of roles not yet evidenced "driftIssues": [ /* DriftIssue[]; see drift-detection.md */ ] } } ``` -`EvidenceValidator` (in `validators.md`) requires *some* evidence: at least one `sourceReferences` entry, or `intentMissing: true`, or `implementationMissing: true`. +`EvidenceValidator` (in `validators.md`) requires *some* evidence: at least one `sourceReferences` entry, or a non-empty `missingRoles`. ### Picking sourceReferences diff --git a/packages/core/skills/shared/validators.md b/packages/core/skills/shared/validators.md index d0542990..eb1c9dd2 100644 --- a/packages/core/skills/shared/validators.md +++ b/packages/core/skills/shared/validators.md @@ -45,8 +45,7 @@ Reads cardinality "one-to-many" left-to-right: `1:N` means each target accepts a **What it checks.** Every node carries an evidence trail: - At least one `metadata.sourceReferences[]` entry, **OR** -- `metadata.implementationMissing: true` (intent-only, code not built yet), **OR** -- `metadata.intentMissing: true` (code-only, intent not written yet). +- a non-empty `metadata.missingRoles[]`, the declared source roles whose evidence is missing. Also: a node with `status: 'completed'` must have at least one `sourceReferences` entry (completion is a claim of fact and requires a citation). @@ -56,11 +55,11 @@ And: every `DriftIssue` attached to a node's `metadata.driftIssues[]` is surface | Code | When | |---|---| -| `evidence.no-source-or-flag` | Node has no sources AND no `intentMissing`/`implementationMissing` flag | +| `evidence.no-source-or-missing-roles` | Node has no sources AND no `missingRoles` | | `evidence.completed-no-source` | Node is `status: completed` but `sourceReferences[]` is empty | | `evidence.drift` | A DriftIssue on the node's metadata (severity passed through; `error` blocks apply) | -**How to self-check.** Treat `metadata` as required on every node you emit. If you have nothing, decide which flag applies (extract from code only means `intentMissing`; extract from PRD only means `implementationMissing`). Never emit empty `metadata`. +**How to self-check.** Treat `metadata` as required on every node you emit. If a node has no source yet, list the declared roles whose evidence is missing in `metadata.missingRoles`. Never emit empty `metadata`. ## 4. OrphanEdgeValidator diff --git a/packages/core/src/domain/validation/OntologyTypeValidator.ts b/packages/core/src/domain/validation/OntologyTypeValidator.ts index 61c1297d..b5491467 100644 --- a/packages/core/src/domain/validation/OntologyTypeValidator.ts +++ b/packages/core/src/domain/validation/OntologyTypeValidator.ts @@ -6,8 +6,9 @@ import type { import type { OntologyPlugin, OntologyValidator } from '../plugin/OntologyPlugin.js' /** - * Generic engine. Reads `nodeTypes` / `edgeTypes` from an `OntologyPlugin`, - * and rejects nodes or edges whose `type` field isn't in the allow-list. + * Generic engine reading an `OntologyPlugin`'s declared vocabulary. + * Rejects any graph reference outside it, a node or edge `type`, + * or a node's `metadata.missingRoles` entry. * * Not a plugin: callers construct an instance bound to their ontology, * typically `defineOntologyPlugin()` in the SDK, @@ -18,10 +19,12 @@ import type { OntologyPlugin, OntologyValidator } from '../plugin/OntologyPlugin export class OntologyTypeValidator implements OntologyValidator { private readonly knownNodeTypes: ReadonlySet private readonly knownEdgeTypes: ReadonlySet + private readonly knownSourceRoles: ReadonlySet constructor(private readonly ontology: OntologyPlugin) { this.knownNodeTypes = new Set(ontology.nodeTypes.map(nodeType => nodeType.id)) this.knownEdgeTypes = new Set(ontology.edgeTypes.map(edgeType => edgeType.id)) + this.knownSourceRoles = new Set(ontology.sourceRoles.map(role => role.id)) } async validate(snapshot: ModelSnapshot): Promise { @@ -35,6 +38,16 @@ export class OntologyTypeValidator implements OntologyValidator { nodeId: node.id, }) } + for (const role of node.metadata.missingRoles ?? []) { + if (!this.knownSourceRoles.has(role)) { + issues.push({ + code: 'ontology.unknown-source-role' as ValidationCode, + severity: 'error', + message: `Node "${node.name}" declares a missing role "${role}" which is not a source role in the ${this.ontology.ontologyId} ontology. Valid roles: ${[...this.knownSourceRoles].join(', ')}.`, + nodeId: node.id, + }) + } + } } for (const edge of snapshot.edges) { if (!this.knownEdgeTypes.has(edge.type)) { diff --git a/packages/core/src/domain/validation/validateEvidence.ts b/packages/core/src/domain/validation/validateEvidence.ts index 74eea1ea..a65db9e4 100644 --- a/packages/core/src/domain/validation/validateEvidence.ts +++ b/packages/core/src/domain/validation/validateEvidence.ts @@ -9,11 +9,12 @@ import type { * Framework invariant: every node must declare some evidence trail. * One of these must hold: * - at least one `metadata.sourceReferences[]` entry, - * - `metadata.implementationMissing: true`, intent-only, code not built yet, - * - `metadata.intentMissing: true`, code-only, intent not written yet. + * - a non-empty `metadata.missingRoles[]`, declared roles whose evidence is missing. * + * The rule is role-agnostic. It never names a role, + * it only asks for a source or an explicit list of roles not yet evidenced. * Without this, the graph silently accepts wishful thinking, - * intent claimed as done with no code and no explicit "not yet" flag. + * a node claimed with no source and no explicit missing-roles list. * * Also catches the contradiction of `status: 'completed'` with zero references, * completion is a claim of fact, and needs at least one source citation. @@ -33,14 +34,13 @@ function checkNode(node: GraphNode): ValidationIssue[] { const issues: ValidationIssue[] = [] const refs = node.metadata.sourceReferences const hasSources = refs.length > 0 - const intentMissing = node.metadata.intentMissing === true - const implementationMissing = node.metadata.implementationMissing === true + const hasMissingRoles = (node.metadata.missingRoles?.length ?? 0) > 0 - if (!hasSources && !intentMissing && !implementationMissing) { + if (!hasSources && !hasMissingRoles) { issues.push({ - code: 'evidence.no-source-or-flag' as ValidationCode, + code: 'evidence.no-source-or-missing-roles' as ValidationCode, severity: 'error', - message: `Node "${node.name}" has no sourceReferences and neither intentMissing nor implementationMissing is set. Every node must declare evidence (a source) or an explicit "not yet" flag.`, + message: `Node "${node.name}" has no sourceReferences and no missingRoles. Every node must declare evidence (a source) or the roles whose evidence is missing.`, nodeId: node.id, }) } diff --git a/packages/core/test/application/HITLServiceHistoryHooks.test.ts b/packages/core/test/application/HITLServiceHistoryHooks.test.ts index 1d69ecfc..8b705aba 100644 --- a/packages/core/test/application/HITLServiceHistoryHooks.test.ts +++ b/packages/core/test/application/HITLServiceHistoryHooks.test.ts @@ -7,6 +7,7 @@ import type { NodeTypeId, ProposalId, SkillId, + SourceRole, UserId, WorkspaceId, } from '@braidhq/schema' @@ -112,7 +113,7 @@ describe('HITLService — workspace history hooks', () => { name: 'voidTask', id: mintTestId('n') as NodeId, status: 'draft' as NodeStatus, - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, }, }], generatedBy: 'extract' as SkillId, diff --git a/packages/core/test/domain/validation/OntologyTypeValidator.test.ts b/packages/core/test/domain/validation/OntologyTypeValidator.test.ts index 2c061df8..99507767 100644 --- a/packages/core/test/domain/validation/OntologyTypeValidator.test.ts +++ b/packages/core/test/domain/validation/OntologyTypeValidator.test.ts @@ -1,4 +1,4 @@ -import type { EdgeId, EdgeTypeId, ModelSnapshot, NodeId, NodeStatus, NodeTypeId } from '@braidhq/schema' +import type { EdgeId, EdgeTypeId, ModelSnapshot, NodeId, NodeStatus, NodeTypeId, SourceRole } from '@braidhq/schema' import { makeOntology } from '@braidhq/test-utils' import { describe, expect, it } from 'vitest' import { OntologyTypeValidator } from '../../../src/domain/validation/OntologyTypeValidator.js' @@ -23,6 +23,7 @@ const tinyOntology = makeOntology({ edgeTypes: [ { id: 'mounts' as EdgeTypeId, fromTypes: ['page'] as NodeTypeId[], toTypes: ['widget'] as NodeTypeId[] }, ], + sourceRoles: [{ id: 'spec', label: 'Spec' }], }) describe('OntologyTypeValidator', () => { @@ -41,6 +42,25 @@ describe('OntologyTypeValidator', () => { expect(issues).toEqual([]) }) + it('accepts a node whose missingRoles are declared source roles', async () => { + const issues = await validator.validate(snapshot([ + { id: 'n1' as NodeId, type: 'page' as NodeTypeId, name: 'home', status: draft, metadata: { sourceReferences: [], missingRoles: ['spec' as SourceRole] } }, + ])) + expect(issues).toEqual([]) + }) + + it('rejects a missingRoles entry not declared by the ontology', async () => { + const issues = await validator.validate(snapshot([ + { id: 'n1' as NodeId, type: 'page' as NodeTypeId, name: 'home', status: draft, metadata: { sourceReferences: [], missingRoles: ['bogus' as SourceRole] } }, + ])) + expect(issues).toHaveLength(1) + expect(issues[0]).toMatchObject({ + code: 'ontology.unknown-source-role', + severity: 'error', + nodeId: 'n1', + }) + }) + it('rejects unknown node types with namespaced code', async () => { const issues = await validator.validate(snapshot([ { id: 'n1' as NodeId, type: 'unknown-type' as NodeTypeId, name: 'x', status: draft, metadata: { sourceReferences: [] } }, diff --git a/packages/core/test/domain/validation/validateEvidence.test.ts b/packages/core/test/domain/validation/validateEvidence.test.ts index 94e15dca..c05c9ce1 100644 --- a/packages/core/test/domain/validation/validateEvidence.test.ts +++ b/packages/core/test/domain/validation/validateEvidence.test.ts @@ -1,4 +1,4 @@ -import type { DriftIssueId, ModelSnapshot, NodeId, NodeStatus, NodeTypeId, SourceId } from '@braidhq/schema' +import type { DriftIssueId, ModelSnapshot, NodeId, NodeStatus, NodeTypeId, SourceId, SourceRole } from '@braidhq/schema' import { describe, expect, it } from 'vitest' import { validateEvidence } from '../../../src/domain/validation/validateEvidence.js' @@ -15,7 +15,7 @@ function snapshot(nodes: ModelSnapshot['nodes'], edges: ModelSnapshot['edges'] = } describe('validateEvidence', () => { - it('emits error when node has no sources and no missing-evidence flag', () => { + it('emits error when node has no sources and no missingRoles', () => { const issues = validateEvidence(snapshot([ { id: 'n1' as NodeId, @@ -27,26 +27,26 @@ describe('validateEvidence', () => { ])) expect(issues).toHaveLength(1) expect(issues[0]).toMatchObject({ - code: 'evidence.no-source-or-flag', + code: 'evidence.no-source-or-missing-roles', severity: 'error', nodeId: 'n1', }) }) - it('accepts node with implementationMissing flag', () => { + it('accepts sourceless node that declares a missing role', () => { const issues = validateEvidence(snapshot([ { id: 'n1' as NodeId, type: aggregate, name: 'Cart', status: draft, - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, }, ])) expect(issues).toEqual([]) }) - it('accepts node with intentMissing flag', () => { + it('accepts a sourced node that also declares a missing role', () => { const issues = validateEvidence(snapshot([ { id: 'n1' as NodeId, @@ -58,7 +58,7 @@ describe('validateEvidence', () => { sourceId: 'code-a' as SourceId, location: { uri: 'apps/api/cart.ts' }, }], - intentMissing: true, + missingRoles: ['alpha' as SourceRole], }, }, ])) @@ -72,10 +72,10 @@ describe('validateEvidence', () => { type: aggregate, name: 'Cart', status: completed, - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, }, ])) - // implementationMissing clears the no-source rule, + // missingRoles clears the no-source rule, // but completed-no-source still fires. expect(issues.map(i => i.code)).toEqual(['evidence.completed-no-source']) }) diff --git a/packages/ontology-ddd/skills/extract/SKILL.md b/packages/ontology-ddd/skills/extract/SKILL.md index 6fc5c076..e309df4e 100644 --- a/packages/ontology-ddd/skills/extract/SKILL.md +++ b/packages/ontology-ddd/skills/extract/SKILL.md @@ -75,12 +75,12 @@ For each candidate node compared to the current graph: For each candidate node, set `metadata` according to where the evidence lives: -- Intent source only (no code yet, e.g. a fresh PRD): set `metadata.sourceReferences = [intent ref]` plus `metadata.implementationMissing = true`. Status stays `draft`. -- Code source only (running code with no spec): set `metadata.sourceReferences = [code ref]` plus `metadata.intentMissing = true`. Status `draft`. +- Intent source only (no code yet, e.g. a fresh PRD): set `metadata.sourceReferences = [intent ref]` plus `metadata.missingRoles = ['code']`. Status stays `draft`. +- Code source only (running code with no spec): set `metadata.sourceReferences = [code ref]` plus `metadata.missingRoles = ['intent']`. Status `draft`. - Both sources agree: set `metadata.sourceReferences = [intent ref, code ref]`. Status `draft` (only the human can promote to `completed` on apply). - Both sources disagree: distinguish identity-level disagreement from field-level drift (see below). -Every node you emit MUST have `metadata` set. A node with `metadata.sourceReferences: []` AND no `implementationMissing` AND no `intentMissing` will be rejected by the server validator. +Every node you emit MUST have `metadata` set. A node with `metadata.sourceReferences: []` AND an empty `metadata.missingRoles` will be rejected by the server validator. When a node has multiple sources to cite (intent plus one or more code files, or several layers of code), order them by representativeness: see `proposal-format.md` § Picking sourceReferences. @@ -125,7 +125,7 @@ Produced N proposals + M clarify tickets: - [ ] Ontology fetched from `braid-core` before any operation was drafted; every `node.type` / `edge.type` matches an id in the response. - [ ] Wiring rules in `ontology-ddd/concept.md` followed (parent edges, no Context Mapping auto-emit, policy has both edges, `dependsOn` is aggregate-to-aggregate). -- [ ] Every node has `metadata.sourceReferences` and / or an `implementationMissing` / `intentMissing` flag. +- [ ] Every node has `metadata.sourceReferences` and / or a non-empty `metadata.missingRoles`. - [ ] Field-level disagreement between sources surfaces as a `DriftIssue` on the node (see `drift-detection.md`), not a ClarifyTicket. - [ ] Each proposal was submitted via `braid-core` proposal-create and the final response was 201 (not 4xx). - [ ] No `removeNode` of a node still referenced elsewhere; deprecate instead. diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 79462568..ee823848 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { DriftIssueId, EdgeId, ExternalReference, NodeId, SkillId, SourceReference, Timestamp } from './common.js' import { EdgeTypeId, NodeStatus, NodeTypeId } from './ontology.js' +import { SourceRole } from './source.js' export const DriftSeverity = z.enum(['error', 'warning', 'info']) export type DriftSeverity = z.infer @@ -31,9 +32,10 @@ export const Embedding = z.object({ export type Embedding = z.infer export const GraphNodeMetadata = z.object({ - intentMissing: z.boolean().optional(), - intentConflict: z.boolean().optional(), - implementationMissing: z.boolean().optional(), + // Declared source roles whose evidence is missing on this node. + // Role-agnostic, an ontology names its own roles, absent means none missing. + // A node with no sources stays valid while a role is still missing. + missingRoles: z.array(SourceRole).optional(), sourceReferences: z.array(SourceReference).default([]), lastTouchedBy: SkillId.optional(), externalReferences: z.array(ExternalReference).optional(), diff --git a/packages/schema/src/proposal-preview.ts b/packages/schema/src/proposal-preview.ts index e52ab36e..e3aff698 100644 --- a/packages/schema/src/proposal-preview.ts +++ b/packages/schema/src/proposal-preview.ts @@ -190,9 +190,7 @@ function nodesEqual(a: GraphNode, b: GraphNode): boolean { && a.name === b.name && a.description === b.description && a.status === b.status - && a.metadata.intentMissing === b.metadata.intentMissing - && a.metadata.intentConflict === b.metadata.intentConflict - && a.metadata.implementationMissing === b.metadata.implementationMissing + && stringArraysEqual(a.metadata.missingRoles, b.metadata.missingRoles) && a.metadata.lastTouchedBy === b.metadata.lastTouchedBy && sourceRefsEqual(a.metadata.sourceReferences, b.metadata.sourceReferences) && externalRefsEqual(a.metadata.externalReferences, b.metadata.externalReferences) @@ -209,6 +207,16 @@ function edgesEqual(a: GraphEdge, b: GraphEdge): boolean { // Structural comparison, not JSON.stringify. Stable across key order, no stringify cost. Array order matters, // since skills typically append. +function stringArraysEqual(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean { + if (a === undefined && b === undefined) + return true + if (a === undefined || b === undefined) + return false + if (a.length !== b.length) + return false + return a.every((value, index) => value === b[index]) +} + function sourceRefsEqual(a: readonly SourceReference[], b: readonly SourceReference[]): boolean { if (a.length !== b.length) return false diff --git a/packages/schema/test/ontology.test.ts b/packages/schema/test/ontology.test.ts index 23383078..a775ec0e 100644 --- a/packages/schema/test/ontology.test.ts +++ b/packages/schema/test/ontology.test.ts @@ -75,11 +75,11 @@ describe('OntologyResponse', () => { it('carries the ontology-declared source roles and their capabilities', () => { const res = OntologyResponse.parse({ - ontologyId: 'ddd', + ontologyId: 'test', nodeTypes: [], edgeTypes: [], - sourceRoles: [{ id: 'intent', label: 'Intent', required: true, unitBearing: true, pathSegment: 'intents' }], + sourceRoles: [{ id: 'alpha', label: 'Alpha', required: true, unitBearing: true, pathSegment: 'alphas' }], }) - expect(res.sourceRoles).toEqual([{ id: 'intent', label: 'Intent', required: true, unitBearing: true, pathSegment: 'intents' }]) + expect(res.sourceRoles).toEqual([{ id: 'alpha', label: 'Alpha', required: true, unitBearing: true, pathSegment: 'alphas' }]) }) }) diff --git a/packages/schema/test/source.test.ts b/packages/schema/test/source.test.ts index a7332659..25fa6196 100644 --- a/packages/schema/test/source.test.ts +++ b/packages/schema/test/source.test.ts @@ -14,9 +14,9 @@ import { describe('SourceRole', () => { it('accepts any non-empty role, so an ontology declares its own set', () => { - expect(SourceRole.parse('code')).toBe('code') - expect(SourceRole.parse('intent')).toBe('intent') - expect(SourceRole.parse('canon')).toBe('canon') + expect(SourceRole.parse('alpha')).toBe('alpha') + expect(SourceRole.parse('beta')).toBe('beta') + expect(SourceRole.parse('gamma')).toBe('gamma') }) it('rejects an empty role', () => { expect(SourceRole.safeParse('').success).toBe(false) @@ -35,7 +35,7 @@ describe('FilesystemSourceDescriptor', () => { const source = FilesystemSourceDescriptor.parse({ kind: 'filesystem', id: 'src-api', - role: 'code', + role: 'alpha', name: 'api', path: '/abs/code/api', }) @@ -47,7 +47,7 @@ describe('FilesystemSourceDescriptor', () => { const source = FilesystemSourceDescriptor.parse({ kind: 'filesystem', id: 'src-api', - role: 'code', + role: 'alpha', name: 'api', path: '/abs/code/api', language: 'typescript', @@ -61,7 +61,7 @@ describe('McpSourceDescriptor', () => { const source = McpSourceDescriptor.parse({ kind: 'mcp', id: 'src-redmine', - role: 'intent', + role: 'beta', name: 'redmine', mcpServerId: 'redmine', }) @@ -73,7 +73,7 @@ describe('McpSourceDescriptor', () => { const source = McpSourceDescriptor.parse({ kind: 'mcp', id: 'src-redmine', - role: 'intent', + role: 'beta', name: 'redmine', mcpServerId: 'redmine', scope: { tags: ['project:DS'], paths: [] }, @@ -87,7 +87,7 @@ describe('SourceDescriptor (discriminated union)', () => { const fs = SourceDescriptor.parse({ kind: 'filesystem', id: 'a', - role: 'code', + role: 'alpha', name: 'a', path: '/abs', }) @@ -96,7 +96,7 @@ describe('SourceDescriptor (discriminated union)', () => { const mcp = SourceDescriptor.parse({ kind: 'mcp', id: 'b', - role: 'intent', + role: 'beta', name: 'b', mcpServerId: 'srv', }) @@ -105,7 +105,7 @@ describe('SourceDescriptor (discriminated union)', () => { it('rejects unknown kind', () => { expect( - SourceDescriptor.safeParse({ kind: 'http', id: 'a', role: 'code', name: 'a' }).success, + SourceDescriptor.safeParse({ kind: 'http', id: 'a', role: 'alpha', name: 'a' }).success, ).toBe(false) }) }) @@ -132,7 +132,7 @@ describe('FilesystemSourceDescriptor with a loader', () => { const source = FilesystemSourceDescriptor.parse({ kind: 'filesystem', id: 'src-app', - role: 'code', + role: 'alpha', name: 'app', path: '/abs/code/app', loader: { kind: 'git', config: { url: 'https://github.com/x/y' } }, diff --git a/packages/server/src/infrastructure/batch/FsBatchPlanRepository.ts b/packages/server/src/infrastructure/batch/FsBatchPlanRepository.ts index 90c07216..6562975d 100644 --- a/packages/server/src/infrastructure/batch/FsBatchPlanRepository.ts +++ b/packages/server/src/infrastructure/batch/FsBatchPlanRepository.ts @@ -6,29 +6,15 @@ import { BatchPlan as BatchPlanData } from '@braidhq/schema' import { z } from 'zod' import { batchPlanPath, workspaceArtifactsDir } from '../_shared/paths.js' -// v2 renamed BatchInputMode from intent|derive to direct|derived. +// On-disk envelope version. Bumped only when the plan payload shape changes. export const BATCH_PLAN_VERSION = 2 -// Envelope only. The plan is validated after migration, not at this layer. +// Envelope only. The plan itself is validated by its schema below. export const BatchPlanFile = z.object({ version: z.number().int(), plan: z.unknown(), }) -// v1 named the batch input mode intent|derive, v2 renamed it direct|derived. -function migrateModeV1ToV2(plan: unknown): unknown { - if (!plan || typeof plan !== 'object' || !('mode' in plan)) - return plan - const legacy = (plan as { mode: unknown }).mode - const mode = legacy === 'intent' ? 'direct' : legacy === 'derive' ? 'derived' : legacy - return { ...(plan as Record), mode } -} - -/** Upgrade a persisted plan payload from its on-disk version to the current schema. */ -function migratePlan(fromVersion: number, plan: unknown): unknown { - return fromVersion < 2 ? migrateModeV1ToV2(plan) : plan -} - export class FsBatchPlanRepository implements BatchPlanRepository { async load(workspace: Workspace): Promise { let raw: string @@ -46,8 +32,7 @@ export class FsBatchPlanRepository implements BatchPlanRepository { `batch-plan.json in ${workspace.rootPath} is version ${file.version}, newer than supported ${BATCH_PLAN_VERSION}`, ) } - const migrated = migratePlan(file.version, file.plan) - return new BatchPlan(BatchPlanData.parse(migrated)) + return new BatchPlan(BatchPlanData.parse(file.plan)) } async save(workspace: Workspace, plan: BatchPlan): Promise { diff --git a/packages/server/test/infrastructure/batch/FsBatchPlanRepository.test.ts b/packages/server/test/infrastructure/batch/FsBatchPlanRepository.test.ts index b1e78b8a..aedfded8 100644 --- a/packages/server/test/infrastructure/batch/FsBatchPlanRepository.test.ts +++ b/packages/server/test/infrastructure/batch/FsBatchPlanRepository.test.ts @@ -1,10 +1,9 @@ import type { AbsolutePath, BatchUnitId, ProposalId } from '@braidhq/schema' -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { makePlan } from '@braidhq/test-utils' import { describe, expect, it } from 'vitest' -import { batchPlanPath, workspaceArtifactsDir } from '../../../src/infrastructure/_shared/paths.js' import { FsBatchPlanRepository } from '../../../src/infrastructure/batch/FsBatchPlanRepository.js' import { makeWorkspace } from '../../helpers/fakes.js' @@ -45,19 +44,6 @@ describe('FsBatchPlanRepository', () => { expect(loaded?.units.find(u => u.id === 'pu-a')?.status).toBe('completed') }) - it('migrates a legacy v1 plan (mode=intent) to the current schema (mode=direct)', async () => { - const root = await makeRoot() - const ws = makeWorkspace({ rootPath: root }) - // Hand-write a pre-rename v1 file, the shape older servers persisted. - const legacyPlan = { ...makePlan({ status: 'running', autoApply: true }).toData(), mode: 'intent' } - await mkdir(workspaceArtifactsDir(root), { recursive: true }) - await writeFile(batchPlanPath(root), JSON.stringify({ version: 1, plan: legacyPlan }), 'utf-8') - - const loaded = await new FsBatchPlanRepository().load(ws) - expect(loaded?.mode).toBe('direct') - expect(loaded?.units.map(u => u.id)).toEqual(['pu-a', 'pu-b']) - }) - it('clear removes the file', async () => { const root = await makeRoot() const ws = makeWorkspace({ rootPath: root }) diff --git a/packages/server/test/integration/e2e.test.ts b/packages/server/test/integration/e2e.test.ts index 1937e6e1..8dc60e7f 100644 --- a/packages/server/test/integration/e2e.test.ts +++ b/packages/server/test/integration/e2e.test.ts @@ -30,9 +30,9 @@ function validNode(opts: { type: string, name: string, id: string }): unknown { type: opts.type, name: opts.name, id: opts.id, - // implementationMissing satisfies EvidenceValidator, the node is intent for code not yet shipped, - // so no sourceReferences needed. - metadata: { sourceReferences: [], implementationMissing: true }, + // missingRoles satisfies EvidenceValidator, + // the node is still missing a role's evidence, so no sourceReferences needed. + metadata: { sourceReferences: [], missingRoles: ['code'] }, } } @@ -112,7 +112,7 @@ describe('e2e: scaffold → submit → validate → apply (post-Model-A-refactor const wsId = await scaffold('e2e-valid') // boundedContext to aggregate via `contains`. Three validators must pass: - // - EvidenceValidator (framework): implementationMissing satisfies it + // - EvidenceValidator (framework): missingRoles satisfies it // - OntologyTypeValidator (ontology): both types declared in ddd // - StructuralValidator (ontology): contains direction matches descriptor const response = await submitProposal(wsId, proposalBody({ @@ -147,7 +147,7 @@ describe('e2e: scaffold → submit → validate → apply (post-Model-A-refactor expect(response.status).toBe(400) const problem = await readJson(response) - expect(problem.issues?.some(issue => issue.code === 'evidence.no-source-or-flag')).toBe(true) + expect(problem.issues?.some(issue => issue.code === 'evidence.no-source-or-missing-roles')).toBe(true) }) it('rejects a proposal whose node type is not in the active ontology', async () => { diff --git a/packages/server/test/integration/historyHooks.test.ts b/packages/server/test/integration/historyHooks.test.ts index c23346be..56a443c8 100644 --- a/packages/server/test/integration/historyHooks.test.ts +++ b/packages/server/test/integration/historyHooks.test.ts @@ -52,7 +52,7 @@ describe('e2e history hooks: applying a proposal writes a commit', () => { }) it('appends an apply commit with Kind / Proposal-Id / Author trailers and updates model.json', async () => { - // Submit a minimal valid proposal, a command node with implementationMissing. + // Submit a minimal valid proposal, a command node with a missing role. // That flag satisfies EvidenceValidator without faking sourceReferences. const submit = await app.request(`/workspaces/${workspaceId}/proposals`, { method: 'POST', @@ -64,7 +64,7 @@ describe('e2e history hooks: applying a proposal writes a commit', () => { type: 'command', name: 'placeOrder', id: 'cmd-place', - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['code'] }, }, }], rationale: 'history hook e2e', @@ -111,7 +111,7 @@ describe('e2e history hooks: applying a proposal writes a commit', () => { type: 'command', name: 'rejectMe', id: 'cmd-rej', - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['code'] }, }, }], rationale: 'will be rejected', diff --git a/packages/server/test/integration/historyRoutes.test.ts b/packages/server/test/integration/historyRoutes.test.ts index 1f85f951..2cce0afc 100644 --- a/packages/server/test/integration/historyRoutes.test.ts +++ b/packages/server/test/integration/historyRoutes.test.ts @@ -48,7 +48,7 @@ describe('history REST routes', () => { body: JSON.stringify({ operations: [{ operation: 'addNode', - payload: { type: 'command', name, id, metadata: { sourceReferences: [], implementationMissing: true } }, + payload: { type: 'command', name, id, metadata: { sourceReferences: [], missingRoles: ['code'] } }, }], rationale: name, generatedBy: 'extract', diff --git a/packages/server/test/routes.test.ts b/packages/server/test/routes.test.ts index ec17fcf2..ff2f9371 100644 --- a/packages/server/test/routes.test.ts +++ b/packages/server/test/routes.test.ts @@ -6,6 +6,7 @@ import type { NodeTypeId, ProposalId, SkillId, + SourceRole, UserId, WorkspaceId, } from '@braidhq/schema' @@ -27,7 +28,7 @@ function validNodePayload(overrides: { type?: NodeTypeId, name?: string, id?: st type: overrides.type ?? COMMAND, name: overrides.name ?? 'x', id: overrides.id ?? 'n-1', - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, } } @@ -158,7 +159,7 @@ describe('POST /workspaces/:ws/proposals/:id/apply', () => { name: 'x', id: 'n-1' as NodeId, status: DRAFT, - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, }, }], })) diff --git a/packages/server/test/routes/ontologies.test.ts b/packages/server/test/routes/ontologies.test.ts index 6a91cdb9..2ab0fa79 100644 --- a/packages/server/test/routes/ontologies.test.ts +++ b/packages/server/test/routes/ontologies.test.ts @@ -16,10 +16,10 @@ describe('GET /ontologies', () => { it('lists each registered ontology with its declared source roles', async () => { const { app, deps } = await buildTestApp() await deps.pluginRegistry.register(makeOntology({ - ontologyId: 'ddd', + ontologyId: 'test', sourceRoles: [ - { id: 'intent', label: 'Intent', required: true, unitBearing: true, pathSegment: 'intents' }, - { id: 'code', label: 'Code', required: true, pathSegment: 'codebases' }, + { id: 'alpha', label: 'Alpha', required: true, unitBearing: true, pathSegment: 'alphas' }, + { id: 'beta', label: 'Beta', required: true, pathSegment: 'betas' }, ], })) @@ -27,8 +27,8 @@ describe('GET /ontologies', () => { expect(response.status).toBe(200) const body = await readJson(response) expect(body.ontologies).toHaveLength(1) - expect(body.ontologies[0]?.ontologyId).toBe('ddd') - expect(body.ontologies[0]?.sourceRoles.map(role => role.id)).toEqual(['intent', 'code']) - expect(body.ontologies[0]?.sourceRoles.find(role => role.id === 'intent')?.unitBearing).toBe(true) + expect(body.ontologies[0]?.ontologyId).toBe('test') + expect(body.ontologies[0]?.sourceRoles.map(role => role.id)).toEqual(['alpha', 'beta']) + expect(body.ontologies[0]?.sourceRoles.find(role => role.id === 'alpha')?.unitBearing).toBe(true) }) }) diff --git a/packages/server/test/routes/showAll.test.ts b/packages/server/test/routes/showAll.test.ts index 93932b5d..990f3d20 100644 --- a/packages/server/test/routes/showAll.test.ts +++ b/packages/server/test/routes/showAll.test.ts @@ -25,7 +25,7 @@ async function submitProposal( type: COMMAND, name: nodeId, id: nodeId, - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['code'] }, }, }], generatedBy: 'extract', diff --git a/packages/server/test/routes/skillInputOptions.test.ts b/packages/server/test/routes/skillInputOptions.test.ts index 94cf421e..a80d96b9 100644 --- a/packages/server/test/routes/skillInputOptions.test.ts +++ b/packages/server/test/routes/skillInputOptions.test.ts @@ -1,4 +1,4 @@ -import type { ClarificationCandidate, ClarificationId, NodeId, NodeStatus, NodeTypeId, WorkspaceId } from '@braidhq/schema' +import type { ClarificationCandidate, ClarificationId, NodeId, NodeStatus, NodeTypeId, SourceRole, WorkspaceId } from '@braidhq/schema' import { Clarification } from '@braidhq/core' import { describe, expect, it } from 'vitest' import { buildTestApp } from '../helpers/buildApp.js' @@ -21,7 +21,7 @@ describe('GET /workspaces/:ws/skill-input-options', () => { type: 'command' as NodeTypeId, name: 'CreateOrder', status: 'draft' as NodeStatus, - metadata: { sourceReferences: [], intentMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, }, }, { @@ -31,7 +31,7 @@ describe('GET /workspaces/:ws/skill-input-options', () => { type: 'event' as NodeTypeId, name: 'OrderPlaced', status: 'draft' as NodeStatus, - metadata: { sourceReferences: [], intentMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, }, }, ]) diff --git a/packages/studio/src/components/graph/NodeDetailPanel.tsx b/packages/studio/src/components/graph/NodeDetailPanel.tsx index 48e7e9af..a1faa663 100644 --- a/packages/studio/src/components/graph/NodeDetailPanel.tsx +++ b/packages/studio/src/components/graph/NodeDetailPanel.tsx @@ -162,12 +162,8 @@ function EdgeList({ title, icon: Icon, edges, getOther, nodesById, onSelectNode function FlagsSection({ node }: { node: GraphNode }) { const flags: Array<{ label: string, tone: 'amber' | 'rose' }> = [] - if (node.metadata.intentMissing) - flags.push({ label: 'intent missing', tone: 'amber' }) - if (node.metadata.intentConflict) - flags.push({ label: 'intent conflict', tone: 'rose' }) - if (node.metadata.implementationMissing) - flags.push({ label: 'implementation missing', tone: 'amber' }) + for (const role of node.metadata.missingRoles ?? []) + flags.push({ label: `missing ${role}`, tone: 'amber' }) if (flags.length === 0) return null return ( diff --git a/packages/test-utils/src/proposal.ts b/packages/test-utils/src/proposal.ts index ca64bc09..db702fd4 100644 --- a/packages/test-utils/src/proposal.ts +++ b/packages/test-utils/src/proposal.ts @@ -1,4 +1,4 @@ -import type { NodeStatus, NodeTypeId, ProposalId, SkillId, WorkspaceId } from '@braidhq/schema' +import type { NodeStatus, NodeTypeId, ProposalId, SkillId, SourceRole, WorkspaceId } from '@braidhq/schema' import { Proposal } from '@braidhq/core' import { mintTestId } from './ids.js' import { T0 } from './time.js' @@ -11,8 +11,9 @@ export interface MakeProposalOptions { /** * Construct a pending Proposal for tests. - * Defaults to a single addNode with implementationMissing, - * the intent-side shape that satisfies the EvidenceValidator invariant. + * Defaults to a single sourceless addNode with a neutral missing role, + * a shape that satisfies the EvidenceValidator invariant, + * without naming an ontology's roles. * Override id when a test asserts on it, * otherwise a fresh minted id keeps calls collision-free. */ @@ -29,7 +30,7 @@ export function makeProposal(workspaceId: WorkspaceId, opts: MakeProposalOptions name, id: mintTestId('n') as never, status: 'draft' as NodeStatus, - metadata: { sourceReferences: [], implementationMissing: true }, + metadata: { sourceReferences: [], missingRoles: ['alpha' as SourceRole] }, }, }], generatedBy: 'extract' as SkillId,