evidence-binder/src/services/bindings.ts
tegwick a10f080f12
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Extract binder package from citation-evidence (EBIND-WP-0001)
Extracts citation-evidence/src/binder/ into this repo as the standalone
@citation-evidence/evidence-binder package: headless binding service +
in-memory link repo, active-state machine, the SharedContracts §7
rect-registry contract (registry, change pumps, hooks, SVG overlay), and
the target-neutral reference FormRenderer.

- toolchain mirrors sibling extracted repos (pnpm/tsc/vitest/eslint);
  imports rewritten from @shared/@engine aliases to the engine's
  @citation-evidence/engine package specifiers
- dependency boundary (engine + anchor only; no source/work/umbrella)
  enforced via eslint no-restricted-imports
- docs: extraction inventory + contract deltas, ADR-0001 (reference UI
  kept as supported exports), refreshed README/SCOPE/INTENT, populated
  capabilities index
- typecheck + lint green, 37 tests passing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:36:33 +02:00

118 lines
4 KiB
TypeScript

/**
* Binding service — links EvidenceItems to structured targets.
*
* Implements `wiki/ArchitectureOverview.md` §4.6 + SharedContracts §2.4
* (status enum), §2.5 (relation enum). Emits §4 events:
* `EvidenceLinkCreated`, `EvidenceLinkUpdated`, `EvidenceItemActivated`.
*
* MVP semantics:
* - `linkEvidenceToTarget` defaults `relation="supports"`, `status="candidate"`.
* - `unlinkEvidence` is hard-delete; the rejected-status path is left to
* a later ADR.
* - `setActiveEvidence` emits an `EvidenceItemActivated` event with
* `source="form-field"` so the viewer/sidebar can react.
*/
import type {
EvidenceLink,
EvidenceLinkStoredStatus,
EvidenceRelation,
EvidenceTarget,
} from "@citation-evidence/engine/shared";
import type { EvidenceItemId, EvidenceLinkId } from "@citation-evidence/engine/shared";
import { newId } from "@citation-evidence/engine/shared";
import type { EventBus } from "@citation-evidence/engine";
import type { EvidenceLinkRepository } from "../repos/in-memory-links";
export interface LinkEvidenceToTargetInput {
readonly evidenceItemId: EvidenceItemId;
readonly target: EvidenceTarget;
readonly relation?: EvidenceRelation;
readonly status?: EvidenceLinkStoredStatus;
readonly confidence?: number;
readonly createdBy?: string;
}
export interface UpdateLinkStatusInput {
readonly status?: EvidenceLinkStoredStatus;
readonly relation?: EvidenceRelation;
readonly confidence?: number;
}
export interface BindingService {
linkEvidenceToTarget(input: LinkEvidenceToTargetInput): EvidenceLink;
unlinkEvidence(id: EvidenceLinkId): boolean;
updateLink(id: EvidenceLinkId, input: UpdateLinkStatusInput): EvidenceLink;
getLink(id: EvidenceLinkId): EvidenceLink | null;
listEvidenceForTarget(target: EvidenceTarget): readonly EvidenceLink[];
listTargetsForEvidence(evidenceItemId: EvidenceItemId): readonly EvidenceLink[];
setActiveEvidence(evidenceItemId: EvidenceItemId): void;
}
export function createBindingService(
links: EvidenceLinkRepository,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): BindingService {
return {
linkEvidenceToTarget(input) {
const ts = now();
const link: EvidenceLink = {
id: newId("evidence-link"),
evidenceItemId: input.evidenceItemId,
targetType: input.target.targetType,
targetId: input.target.targetId,
relation: input.relation ?? "supports",
status: input.status ?? "candidate",
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = links.create(link);
bus.emit({ type: "EvidenceLinkCreated", linkId: stored.id, link: stored });
return stored;
},
unlinkEvidence(id) {
const removed = links.delete(id);
if (removed) {
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
}
return removed;
},
updateLink(id, input) {
const existing = links.get(id);
if (!existing) {
throw new Error(`BindingService.updateLink: unknown id ${id}`);
}
const next: EvidenceLink = {
...existing,
...(input.status !== undefined ? { status: input.status } : {}),
...(input.relation !== undefined ? { relation: input.relation } : {}),
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
updatedAt: now(),
};
const stored = links.update(next);
bus.emit({ type: "EvidenceLinkUpdated", linkId: stored.id, link: stored });
return stored;
},
getLink(id) {
return links.get(id);
},
listEvidenceForTarget(target) {
return links.listForTarget(target);
},
listTargetsForEvidence(evidenceItemId) {
return links.listForEvidenceItem(evidenceItemId);
},
setActiveEvidence(evidenceItemId) {
bus.emit({
type: "EvidenceItemActivated",
evidenceItemId,
source: "form-field",
});
},
};
}