Skip to content

feat: decouple intent/code from core so braid is a pure framework (ontology-declared SourceRoles) #50

Description

@mroops0111

Summary

braid is meant to be a pure framework where the ontology is a plugin (DDD is just the default). But the intent / code worldview is still hard-coded into core, which blocks any non-software ontology (for example a research-notes ontology with roles note / reference) from being a first-class citizen.

This issue tracks decoupling the source-role taxonomy from core so braid is genuinely "braid anything". The bar is OCP: adding a new ontology with its own source roles must require zero edits to @braidhq/core and @braidhq/schema.

Guiding principle

The framework must never branch on a role's identity. It branches only on a capability the ontology declares for that role.

Every leak below is the same violation, asking "are you the thing called intent?" (role === 'intent', mode === 'intent', kind === 'source-intent'). The fix is to ask instead "do you have the capability its sources are enumerated into batch units?". The role name disappears from the framework; the capability stays. The Information Expert for role semantics is the ontology plugin, not core, not the source, not the role name.

Current leaks (verified inventory)

Single root:

  • packages/schema/src/source.ts SourceRole = z.enum(['code', 'intent']). A closed enum pins the taxonomy. Everything downstream is either this enum flowing through SourceDescriptor.role, or a literal comparison against it.

Framework branches on role identity (these are the OCP violations):

  • packages/core/src/domain/workspace/Workspace.ts codeSources() / intentSources() filter on the literal role.
  • packages/core/src/application/BatchService.ts resolveMode (intent vs code vs derive), the mode === 'intent' branch, and buildIntentUnits / IntentLister.
  • packages/core/src/application/ReactorService.ts isIntentSource gates reactive re-derivation on role === 'intent'; code sources fall through.
  • packages/schema/src/batch.ts BatchInputMode = z.enum(['intent', 'derive']). The intent value is a framework mode name borrowed from the role.
  • packages/schema/src/skill.ts SkillInputSourceIntentProvider (kind: 'source-intent') enumerates only role:intent sources for skill forms.
  • packages/core/src/domain/plugin/OntologyPlugin.ts requiredSourceRoles is the only role hook an ontology has, and it is typed readonly ('code' | 'intent')[]. Same narrow type is duplicated in packages/sdk/src/defineOntologyPlugin.ts.

Server:

  • packages/server/src/infrastructure/source/intentScan.ts skips any role !== 'intent' source.
  • packages/server/src/routes/skills.ts rejects a sourceUnit whose source is not role === 'intent'.
  • packages/server/src/routes/ontology.ts and packages/schema/src/ontology.ts OntologyResponse expose node and edge types but not roles, so no client can learn an ontology's role vocabulary.

Client:

  • packages/studio/src/lib/sourceDraft.ts rolePathSegment hard-maps role to the intents / codebases folder.
  • packages/studio/src/components/AddSourceDialog.tsx and CreateWorkspaceWizard.tsx hard-code the two roles as dropdown options, defaults, and labels.
  • packages/studio/src/pages/Batch.tsx carries DDD-specific prose ("Reads every registered intent doc", "Will Scan Codebases") that a non-software ontology would render wrong.

Already generic (leave as-is, these are the pattern to follow):

  • packages/core/src/application/WorkspaceService.ts assertRequiredSourceRoles already diffs presence against a declared list.
  • packages/schema/src/source.ts LoaderKind is already an open branded string ("new loaders need no edit here"). This is the exact precedent for SourceRole.
  • packages/core/src/domain/validation/validateEvidence.ts drift surfacing is already generic (it renders driftIssues that upstream skills write). Only the two named flags metadata.intentMissing / implementationMissing (packages/schema/src/model.ts) still bake the worldview in.

Proposed design

1. Open the role type

SourceRole becomes an open branded string, matching the LoaderKind precedent. The intent / code values leave @braidhq/schema and become data the DDD ontology declares.

export const SourceRole = z.string().min(1).brand<'SourceRole'>()

2. Ontology declares a role vocabulary with capabilities

requiredSourceRoles is replaced by a descriptor list on OntologyPlugin. The capability, not the name, is what the framework reads.

export interface SourceRoleDescriptor {
  readonly id: SourceRole
  readonly label: string
  /** Sources of this role must be present for the ontology to run. */
  readonly required?: boolean
  /**
   * Sources of this role are enumerated directly into batch units,
   * and their sync triggers the Reactor. The name is provisional.
   */
  readonly unitBearing?: boolean
  /** Workspace subfolder these sources provision into. */
  readonly pathSegment?: string
}

// on OntologyPlugin, replacing requiredSourceRoles
readonly sourceRoles: readonly SourceRoleDescriptor[]

required is derived by sourceRoles.filter(r => r.required), so assertRequiredSourceRoles keeps working unchanged. unitBearing is a single capability that drives both batch unit production and reactor gating. They lock onto the same set of sources, so they do not need separate flags. A future divergence can add a second capability without breaking anyone.

3. Framework derives batch behavior from the capability

BatchInputMode stays a closed framework enum (two exhaustive modes) but loses the role name:

export const BatchInputMode = z.enum(['direct', 'derive'])

BatchService decides purely from declarations, with no role literal:

private resolveMode(workspace, ontology, binding): BatchInputMode {
  const unitRoles = ontology.sourceRoles.filter(r => r.unitBearing).map(r => r.id)
  if (unitRoles.some(role => workspace.sourcesWithRole(role).length > 0))
    return 'direct'
  if (binding.deriveUnits)
    return 'derive'
  throw new ValidationError(...)
}

Layering: Workspace exposes a pure sourcesWithRole(role: SourceRole), replacing intentSources() / codeSources(). The domain never learns which roles are unit-bearing; the application layer, which already resolves the ontology, does that lookup and calls back into the pure accessor. Domain stays free of the plugin registry.

The same unitBearing capability re-points four call sites: resolveMode, ReactorService.isIntentSource (becomes isUnitBearingSource), intentScan (becomes a generic unitScan injected as unitLister), and the sourceUnit guard in routes/skills.ts.

4. Skill-input source provider becomes role-parameterized

export const SkillInputSourceProvider = z.object({
  kind: z.literal('source'),
  filter: z.object({
    role: SourceRole.optional(),
    loaderKind: z.string().optional(),
  }).optional(),
})

The DDD extract skill declares role: 'intent' itself. The framework and Studio only know the role parameter.

5. Expose roles to the client

OntologyResponse gains sourceRoles (id, label, capabilities). Studio's Add-Source dropdown, wizard defaults, rolePathSegment, and the Batch-page copy all become driven by the active ontology's declared roles instead of hard-coded literals.

6. SDK

@braidhq/sdk re-exports the SourceRole type, and defineOntologyPlugin takes sourceRoles in place of the narrowly-typed requiredSourceRoles.

7. Evidence flags (coordinate with #68)

Generalize metadata.intentMissing / implementationMissing into a role-agnostic notion of "no evidence yet from role X". This is the cross-role corroboration model that #68 (trust for domains with no privileged fact source) also needs, so the two should be designed together rather than piecemeal.

8. Tests

Framework and @braidhq/test-utils fixtures move to a neutral test ontology (for example roles alpha / beta). Any test that legitimately exercises intent / code moves into packages/ontology-ddd/test.

Acceptance criteria

  • A third-party ontology can declare its own SourceRole set and run the full extract, propose, HITL, model, view loop without touching @braidhq/core or @braidhq/schema.
  • Litmus test: grep -rn "'intent'\|'code'\|source-intent" packages/*/src packages/*/test hits only packages/ontology-ddd/**.
  • ontology-ddd becomes a clean reference: it declares code / intent as data instead of relying on core knowing those names.

Invariants to keep

The HITL gate and the evidence / provenance rules stay. They are framework invariants, not part of the software-specific taxonomy. Only the source-role taxonomy is being lifted out.

Suggested staging

  1. Open the type and the capability model. Schema role becomes a brand string, SDK gains sourceRoles, core de-hardcodes the four call sites, BatchInputMode renamed. DDD declares its two roles. Framework green.
  2. End-to-end. Server unitScan and the roles endpoint, Studio driven by declared roles, skill-input provider generalized. A non-DDD ontology runs the full loop.
  3. Evidence generalization, together with design: trust / convergence model for domains with no privileged fact-SSoT #68.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions