Implement CE-WP-0009: wire umbrella to @citation-evidence/engine

Add link: dependency on citation-engine, retarget @shared/@engine aliases,
remove in-repo shared/engine copies. ADR-0002 accepted (option B).
172 tests, typecheck, and lint pass.
This commit is contained in:
2026-06-22 19:45:11 +02:00
parent bb911eef37
commit dd2f2115bd
60 changed files with 93 additions and 3942 deletions

View File

@@ -4,9 +4,9 @@ A document-centered evidence workspace for capturing, managing, presenting,
and re-opening citations. The umbrella over the six-package design described
in `INTENT.md` and `wiki/ArchitectureOverview.md`.
During the MVP all code lives here under `src/` (see "Repository layout"
below). Sister repos hold INTENT only — code migrates outward when each
subsystem stabilises.
Shared types and engine services live in the extracted
[`@citation-evidence/engine`](../citation-engine/) package (`link:../citation-engine`).
Remaining partitions stay under `src/` until each subsystem extracts.
## Documentation
@@ -24,10 +24,10 @@ Both are referenced from every workplan and from each sister repo's INTENT.md.
## Repository layout
Requires sibling checkout: `../citation-engine` (see `package.json` `link:` dep).
```
src/
shared/ # vocabulary, types, pure helpers → becomes part of citation-engine
engine/ # services, repositories, event bus → becomes part of citation-engine
anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor
source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source
binder/ # evidence-to-target binding, visual guide → becomes evidence-binder
@@ -41,9 +41,9 @@ repo is intended to be a `git mv` plus a `package.json` cut — nothing more.
## Sister repos
Peers under `~/`; each holds INTENT.md only during MVP:
Peers under `~/`:
- [`~/citation-engine`](../citation-engine/) — shared model + engine services
- [`~/citation-engine`](../citation-engine/) — **extracted** shared model + engine (`@citation-evidence/engine`)
- [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract
- [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery
- [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry
@@ -54,6 +54,7 @@ Peers under `~/`; each holds INTENT.md only during MVP:
Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9.
```bash
# citation-engine must be checked out next to this repo (../citation-engine)
pnpm install
pnpm dev # vite dev server (once src/app/ has a real entry)
pnpm test # vitest one-shot

View File

@@ -1,16 +1,19 @@
# ADR-0002 — Monorepo vs polyrepo for the six subsystems
- Status: proposed
- Status: accepted
- Date: 2026-05-24
- Workplan: CE-WP-0001-T07 (stub)
- Decided: 2026-06-22
- Workplan: CENG-WP-0002-T01
## Context
The umbrella-first MVP lives entirely in `citation-evidence/` under
`src/{shared,engine,anchor,source,binder,work,app}/`. Each folder is named
after its eventual extracted package. At some point — driven by an external
consumer needing one subsystem, or by independent release cadence — code
will move out into its sister repo.
`src/{anchor,source,binder,work,app}/` with shared types and engine services
in the extracted `@citation-evidence/engine` package (`citation-engine` repo).
Each remaining folder is named after its eventual extracted package. At some
point — driven by an external consumer needing one subsystem, or by independent
release cadence — code will move out into its sister repo.
We need a written answer to: when that moment comes, do we (a) keep one
repository with pnpm workspaces, (b) split into six independent repos with
@@ -43,8 +46,29 @@ across the boundary.
## Decision
(blank — to be answered before the first subsystem extraction lands.)
**B — six independent repos with published packages**, using **`link:` sibling
dependencies during local development** until a registry is configured.
Rationale:
1. The ecosystem is already organized as six sister repos plus the umbrella;
independent repos match the documented architecture.
2. `citation-engine` extraction (`CENG-WP-0001`) and umbrella wireup
(`CE-WP-0009`) prove the `link:../citation-engine` dev workflow.
3. Publishing can be deferred — no registry is configured yet — without
blocking extraction of the remaining subsystems.
4. Option C adds tooling overhead before any external consumer exists.
## Consequences
(blank)
- **Local dev:** sister repos sit as siblings under `~/` (or equivalent).
Consumers declare `"@citation-evidence/engine": "link:../citation-engine"`.
- **Publishing:** when a registry is chosen, bump `@citation-evidence/engine`
semver and replace `link:` with the registry version in consumer repos.
- **Contracts:** `citation-evidence/wiki/SharedContracts.md` stays authoritative;
`citation-engine/wiki/SharedContracts.md` is a conformance copy (see
`citation-engine/wiki/README.md`).
- **Versioning:** engine package semver tracks API/contract changes; umbrella
and sister repos pin or range-pin on publish.
- **CI:** each repo runs its own test/lint pipeline; cross-repo integration
tests remain in `citation-evidence` until subsystems extract fully.

View File

@@ -21,8 +21,8 @@ CE-WP-0008 fixes capture field value persistence and viewport scroll reliability
| Workplan | Repo | Title | Status |
|----------|------|-------|--------|
| `CENG-WP-0001` | citation-engine | Extract engine from umbrella | done |
| `CE-WP-0009` | citation-evidence | Wire umbrella to `@citation-evidence/engine` | active |
| `CENG-WP-0002` | citation-engine | Package distribution (ADR-0002, publish prep) | active |
| `CE-WP-0009` | citation-evidence | Wire umbrella to `@citation-evidence/engine` | done |
| `CENG-WP-0002` | citation-engine | Package distribution (ADR-0002, publish prep) | done |
`CE-WP-0009` depends on `CENG-WP-0001`. `CENG-WP-0002` can run in parallel;
publish tasks wait on ADR-0002 resolution.

View File

@@ -1,23 +1,28 @@
// ESLint flat config (ESLint 9+).
// Enforces the partition dependency map in wiki/DependencyMap.md §4.
//
// shared/ and engine/ live in the linked @citation-evidence/engine package;
// boundary rules for those partitions are enforced in citation-engine.
//
// Element types (folders) and allowed importers:
// shared : importable by every other element (no internal imports of its own).
// engine : imports shared.
// shared : importable by every other element (package: citation-engine).
// engine : imports shared (package: citation-engine).
// anchor : imports shared, engine.
// source : imports shared, engine.
// binder : imports shared, engine, anchor.
// work : imports shared, engine, anchor, source. (NOT binder.)
// app : imports anything.
//
// Path aliases (@shared/*, @engine/*, etc.) come from tsconfig.json paths and
// are resolved by eslint-import-resolver-typescript.
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import boundaries from "eslint-plugin-boundaries";
import importPlugin from "eslint-plugin-import";
import globals from "globals";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const engineSrc = resolve(__dirname, "../citation-engine/src");
export default tseslint.config(
{
@@ -41,8 +46,8 @@ export default tseslint.config(
typescript: { project: "./tsconfig.json" },
},
"boundaries/elements": [
{ type: "shared", pattern: "src/shared/**" },
{ type: "engine", pattern: "src/engine/**" },
{ type: "shared", pattern: `${engineSrc}/shared/**` },
{ type: "engine", pattern: `${engineSrc}/engine/**` },
{ type: "anchor", pattern: "src/anchor/**" },
{ type: "source", pattern: "src/source/**" },
{ type: "binder", pattern: "src/binder/**" },
@@ -68,4 +73,4 @@ export default tseslint.config(
],
},
},
);
);

View File

@@ -19,6 +19,7 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@citation-evidence/engine": "link:../citation-engine",
"jszip": "^3.10.1",
"pdfjs-dist": "^4.4.168",
"react": "^18.3.1",

3
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@citation-evidence/engine':
specifier: link:../citation-engine
version: link:../citation-engine
jszip:
specifier: ^3.10.1
version: 3.10.1

View File

@@ -31,7 +31,7 @@ describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => {
const scrollToHighlight = vi.fn();
const centerHighlight = vi.fn();
let utils: PdfHighlighterUtils | null = null;
let highlight: Highlight | undefined;
const highlightRef: { current: Highlight | undefined } = { current: undefined };
const state = { lastCompletedKey: null as string | null };
@@ -39,7 +39,7 @@ describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => {
{ requestKey: "ann_test:1", annotationId: "ann_test" },
{
getUtils: () => utils,
findHighlight: (id) => (id === "ann_test" ? highlight : undefined),
findHighlight: (id) => (id === "ann_test" ? highlightRef.current : undefined),
scrollToHighlight: (_u, target) => scrollToHighlight(target),
centerHighlight,
scheduleFrame: (fn) => {
@@ -59,7 +59,7 @@ describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => {
expect(scrollToHighlight).not.toHaveBeenCalled();
utils = { scrollToHighlight: vi.fn() } as unknown as PdfHighlighterUtils;
highlight = TARGET;
highlightRef.current = TARGET;
frames.shift()?.();
expect(scrollToHighlight).toHaveBeenCalledWith(TARGET);

View File

@@ -172,14 +172,20 @@ function SessionScopedTree({ mode }: { mode: AppMode }) {
return (
<BinderProvider
bus={engine.bus}
initialLinks={restoredCapture?.evidenceLinks}
{...(restoredCapture?.evidenceLinks
? { initialLinks: restoredCapture.evidenceLinks }
: {})}
>
<CaptureLinkPersister sessionId={sessionId} />
{mode === "forms" ? (
<FormsApp
sessionId={sessionId}
initialSchema={restoredCapture?.formSchema}
initialFieldValues={restoredCapture?.fieldValues}
{...(restoredCapture?.formSchema
? { initialSchema: restoredCapture.formSchema }
: {})}
{...(restoredCapture?.fieldValues
? { initialFieldValues: restoredCapture.fieldValues }
: {})}
/>
) : (
<ReviewLayout upload={<UploadDropzone />} />

View File

@@ -4,7 +4,8 @@
import { describe, expect, it } from "vitest";
import type { SessionId } from "@shared/ids";
import type { EvidenceLink } from "@shared/evidence-link";
import type { EvidenceItemId, EvidenceLinkId, SessionId } from "@shared/ids";
import {
CAPTURE_STATE_VERSION,
@@ -47,17 +48,17 @@ describe("capture-persistence", () => {
fieldValues: { summary: "Tenant owes arrears", deadline: "2026-12-15" },
evidenceLinks: [
{
id: "evlink_1",
evidenceItemId: "evi_1",
id: "evlink_1" as EvidenceLinkId,
evidenceItemId: "evi_1" as EvidenceItemId,
targetType: "form-field",
targetId: "summary",
relation: "supports",
status: "candidate",
createdAt: "2026-06-08T00:00:00.000Z",
updatedAt: "2026-06-08T00:00:00.000Z",
},
} satisfies EvidenceLink,
],
} as const;
};
saveCaptureState(SESSION, withData, storage);
const loaded = loadCaptureState(SESSION, storage);

View File

@@ -9,7 +9,7 @@
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
*/
import { useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode } from "react";
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
import type { EvidenceTarget } from "@shared/evidence-link";
@@ -298,7 +298,9 @@ export function FormRenderer({
field={field}
value={values?.[field.id] ?? ""}
linkCount={linkCounts?.[field.id] ?? 0}
linkHint={linkHints?.[field.id]}
{...(linkHints?.[field.id] != null
? { linkHint: linkHints[field.id] }
: {})}
isActive={isFieldActive(state, field.id)}
isEditing={editingFieldId === field.id}
editLabel={editLabel}

View File

@@ -1,7 +0,0 @@
# `src/engine/` — services, repositories, event bus
Future home: `citation-engine` (the services half).
Owns: repositories for `Document`/`Annotation`/`EvidenceItem`/`EvidenceLink`,
ID generation orchestration, the event bus, and pure orchestration services.
May import from: `shared/` only (`wiki/DependencyMap.md` §4).

View File

@@ -1,168 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import { createEngine, type Engine, type EngineEvent } from "./index";
function fakeDocAndRep(): { document: Document; representation: DocumentRepresentation } {
const docId = "doc_fake" as DocumentId;
const repId = "rep_fake" as RepresentationId;
return {
document: {
id: docId,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: "h",
canonicalText: "The quick brown fox.",
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
};
}
describe("Engine integration", () => {
let engine: Engine;
let events: EngineEvent[];
beforeEach(() => {
engine = createEngine();
events = [];
engine.bus.onAny((e) => events.push(e));
});
it("documentService.register stores both and emits DocumentImported + DocumentRepresentationGenerated", () => {
const { document, representation } = fakeDocAndRep();
const result = engine.documents.register({ document, representation });
expect(result.document).toBe(document);
expect(result.representation).toBe(representation);
expect(engine.documents.get(document.id)).toBe(document);
expect(engine.documents.getRepresentation(representation.id)).toBe(representation);
expect(events.map((e) => e.type)).toEqual(["DocumentImported", "DocumentRepresentationGenerated"]);
});
it("annotationService.create stamps an ID + normalize version + timestamps, then emits AnnotationCreated", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const selectors: Selector[] = [{ type: "TextQuoteSelector", exact: "brown fox" }];
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors,
quote: "brown fox",
note: "a quick mark",
});
expect(ann.id).toMatch(/^ann_/);
expect(ann.normalizeVersion).toBeGreaterThan(0);
expect(ann.createdAt).toBe(ann.updatedAt);
expect(engine.annotations.get(ann.id)).toBe(ann);
const created = events.find((e) => e.type === "AnnotationCreated");
expect(created?.type).toBe("AnnotationCreated");
});
it("setResolutionStatus emits AnnotationResolved for resolved/ambiguous and AnnotationResolutionFailed for unresolved/stale", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
});
events.length = 0;
engine.annotations.setResolutionStatus(ann.id, "resolved", { confidence: 0.95 });
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved"]);
engine.annotations.setResolutionStatus(ann.id, "unresolved", { confidence: 0, reason: "no quote match" });
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved", "AnnotationResolutionFailed"]);
});
it("evidenceService.create requires at least one annotation and emits EvidenceItemCreated", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
});
expect(() => engine.evidence.create({ annotationIds: [] })).toThrow();
const item = engine.evidence.create({
annotationIds: [ann.id],
commentary: "good quote",
});
expect(item.status).toBe("candidate");
expect(item.annotationIds).toEqual([ann.id]);
expect(events.find((e) => e.type === "EvidenceItemCreated")).toBeDefined();
});
it("setStatus emits EvidenceItemUpdated only on real change and carries previousStatus", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
});
const item = engine.evidence.create({ annotationIds: [ann.id] });
events.length = 0;
const same = engine.evidence.setStatus(item.id, "candidate");
expect(same).toBe(item);
expect(events).toEqual([]);
engine.evidence.setStatus(item.id, "confirmed");
const updated = events.find((e) => e.type === "EvidenceItemUpdated");
expect(updated).toBeDefined();
if (updated?.type === "EvidenceItemUpdated") {
expect(updated.previousStatus).toBe("candidate");
}
});
it("listByDocument scopes evidence items to a single document via annotation lookup", () => {
const a = fakeDocAndRep();
engine.documents.register(a);
const annA = engine.annotations.create({
documentId: a.document.id,
representationId: a.representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
});
engine.evidence.create({ annotationIds: [annA.id], commentary: "a" });
// Second, distinct document.
const otherDocId = "doc_other" as DocumentId;
const otherRepId = "rep_other" as RepresentationId;
engine.documents.register({
document: { ...a.document, id: otherDocId },
representation: { ...a.representation, id: otherRepId, documentId: otherDocId },
});
const annB = engine.annotations.create({
documentId: otherDocId,
representationId: otherRepId,
selectors: [{ type: "TextQuoteSelector", exact: "z" }],
});
engine.evidence.create({ annotationIds: [annB.id], commentary: "b" });
expect(engine.evidence.listByDocument(a.document.id)).toHaveLength(1);
expect(engine.evidence.listByDocument(otherDocId)).toHaveLength(1);
});
it("activate emits EvidenceItemActivated without mutating the item", () => {
const { document, representation } = fakeDocAndRep();
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
});
const item = engine.evidence.create({ annotationIds: [ann.id] });
events.length = 0;
engine.evidence.activate(item.id, "sidebar");
const activated = events.find((e) => e.type === "EvidenceItemActivated");
expect(activated).toBeDefined();
if (activated?.type === "EvidenceItemActivated") {
expect(activated.source).toBe("sidebar");
}
});
});

View File

@@ -1,64 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { DocumentId } from "@shared/ids";
import { createEventBus } from "./bus";
const docId = "doc_test" as DocumentId;
const minimalDoc = {
id: docId,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
describe("EventBus", () => {
it("delivers typed events to the registered listener", () => {
const bus = createEventBus();
const spy = vi.fn();
bus.on("DocumentImported", spy);
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0]![0]).toMatchObject({ type: "DocumentImported", documentId: docId });
expect(result.listenerCount).toBe(1);
expect(result.errors).toEqual([]);
});
it("does not deliver an event to listeners of a different type", () => {
const bus = createEventBus();
const spy = vi.fn();
bus.on("AnnotationCreated", spy);
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(spy).not.toHaveBeenCalled();
});
it("delivers every event to onAny listeners", () => {
const bus = createEventBus();
const spy = vi.fn();
bus.onAny(spy);
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
bus.emit({ type: "EvidenceItemActivated", evidenceItemId: "ev_x" as never });
expect(spy).toHaveBeenCalledTimes(2);
});
it("returns an unsubscribe function from on()", () => {
const bus = createEventBus();
const spy = vi.fn();
const off = bus.on("DocumentImported", spy);
off();
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(spy).not.toHaveBeenCalled();
});
it("captures listener errors and still calls subsequent listeners", () => {
const bus = createEventBus();
const boom = new Error("listener exploded");
const a = vi.fn(() => { throw boom; });
const b = vi.fn();
bus.on("DocumentImported", a);
bus.on("DocumentImported", b);
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
expect(result.errors).toEqual([boom]);
expect(result.listenerCount).toBe(2);
});
});

View File

@@ -1,79 +0,0 @@
/**
* Synchronous in-process event bus.
*
* Listeners fire in registration order on the calling stack; `emit` returns
* after every listener has run. A listener throwing does not stop later
* listeners — its error surfaces through the returned `errors` array so
* callers can decide whether to log, rethrow, or ignore.
*
* MVP-sufficient. ADR-0005 (persistence) will decide whether to upgrade to
* an async/queued bus when storage becomes durable.
*/
import type { EngineEvent, EngineEventOf, EngineEventType } from "./types";
export type EngineEventListener<T extends EngineEventType = EngineEventType> = (
event: EngineEventOf<T>,
) => void;
export type AnyEngineEventListener = (event: EngineEvent) => void;
export interface EmitResult {
readonly listenerCount: number;
readonly errors: readonly unknown[];
}
export interface EventBus {
on<T extends EngineEventType>(type: T, listener: EngineEventListener<T>): () => void;
onAny(listener: AnyEngineEventListener): () => void;
emit<T extends EngineEventType>(event: EngineEventOf<T>): EmitResult;
}
export function createEventBus(): EventBus {
const typedListeners = new Map<EngineEventType, Set<EngineEventListener>>();
const anyListeners = new Set<AnyEngineEventListener>();
return {
on(type, listener) {
let set = typedListeners.get(type);
if (!set) {
set = new Set();
typedListeners.set(type, set);
}
set.add(listener as unknown as EngineEventListener);
return () => {
set!.delete(listener as unknown as EngineEventListener);
};
},
onAny(listener) {
anyListeners.add(listener);
return () => {
anyListeners.delete(listener);
};
},
emit(event) {
const errors: unknown[] = [];
let count = 0;
const typedSet = typedListeners.get(event.type);
if (typedSet) {
for (const l of typedSet) {
count++;
try {
(l as AnyEngineEventListener)(event);
} catch (err) {
errors.push(err);
}
}
}
for (const l of anyListeners) {
count++;
try {
l(event);
} catch (err) {
errors.push(err);
}
}
return { listenerCount: count, errors };
},
};
}

View File

@@ -1,8 +0,0 @@
export * from "./types";
export {
createEventBus,
type EventBus,
type EngineEventListener,
type AnyEngineEventListener,
type EmitResult,
} from "./bus";

View File

@@ -1,159 +0,0 @@
/**
* Engine event vocabulary.
*
* Implements `wiki/SharedContracts.md` §4 (closed event list). Each event
* carries the *minimum* identifying payload needed by downstream listeners;
* services hand back the full domain object to the caller separately.
*
* Adding an event requires updating SharedContracts.md first.
*/
import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence";
import type {
EvidenceLink,
EvidenceTarget,
} from "@shared/evidence-link";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
EvidenceLinkId,
RepresentationId,
SessionId,
} from "@shared/ids";
import type { Session } from "@shared/session";
export interface DocumentImportedEvent {
readonly type: "DocumentImported";
readonly documentId: DocumentId;
readonly document: Document;
}
export interface DocumentRepresentationGeneratedEvent {
readonly type: "DocumentRepresentationGenerated";
readonly documentId: DocumentId;
readonly representationId: RepresentationId;
readonly representation: DocumentRepresentation;
}
export interface DocumentRemovedEvent {
readonly type: "DocumentRemoved";
readonly documentId: DocumentId;
}
export interface AnnotationCreatedEvent {
readonly type: "AnnotationCreated";
readonly annotationId: AnnotationId;
readonly annotation: Annotation;
}
export interface AnnotationResolvedEvent {
readonly type: "AnnotationResolved";
readonly annotationId: AnnotationId;
readonly status: AnnotationResolutionStatus;
readonly confidence: number;
}
export interface AnnotationResolutionFailedEvent {
readonly type: "AnnotationResolutionFailed";
readonly annotationId: AnnotationId;
readonly reason: string;
}
export interface AnnotationUpdatedEvent {
readonly type: "AnnotationUpdated";
readonly annotationId: AnnotationId;
readonly annotation: Annotation;
}
export interface EvidenceItemCreatedEvent {
readonly type: "EvidenceItemCreated";
readonly evidenceItemId: EvidenceItemId;
readonly evidenceItem: EvidenceItem;
}
export interface EvidenceItemUpdatedEvent {
readonly type: "EvidenceItemUpdated";
readonly evidenceItemId: EvidenceItemId;
readonly evidenceItem: EvidenceItem;
readonly previousStatus: EvidenceItemStatus;
}
export interface EvidenceItemActivatedEvent {
readonly type: "EvidenceItemActivated";
readonly evidenceItemId: EvidenceItemId;
readonly source?: "sidebar" | "form-field" | "citation-card";
}
export interface EvidenceLinkCreatedEvent {
readonly type: "EvidenceLinkCreated";
readonly linkId: EvidenceLinkId;
readonly link: EvidenceLink;
}
export interface EvidenceLinkUpdatedEvent {
readonly type: "EvidenceLinkUpdated";
readonly linkId: EvidenceLinkId;
readonly link: EvidenceLink;
}
export interface EvidenceLinkRemovedEvent {
readonly type: "EvidenceLinkRemoved";
readonly linkId: EvidenceLinkId;
}
export interface FormFieldActivatedEvent {
readonly type: "FormFieldActivated";
readonly target: EvidenceTarget;
readonly previousTarget?: EvidenceTarget;
}
export interface SessionCreatedEvent {
readonly type: "SessionCreated";
readonly sessionId: SessionId;
readonly session: Session;
}
export interface SessionRenamedEvent {
readonly type: "SessionRenamed";
readonly sessionId: SessionId;
readonly session: Session;
readonly previousName: string;
}
export interface SessionDeletedEvent {
readonly type: "SessionDeleted";
readonly sessionId: SessionId;
}
export interface SessionActivatedEvent {
readonly type: "SessionActivated";
readonly sessionId: SessionId | null;
readonly previousSessionId: SessionId | null;
}
export type EngineEvent =
| DocumentImportedEvent
| DocumentRepresentationGeneratedEvent
| DocumentRemovedEvent
| AnnotationCreatedEvent
| AnnotationUpdatedEvent
| AnnotationResolvedEvent
| AnnotationResolutionFailedEvent
| EvidenceItemCreatedEvent
| EvidenceItemUpdatedEvent
| EvidenceItemActivatedEvent
| EvidenceLinkCreatedEvent
| EvidenceLinkUpdatedEvent
| EvidenceLinkRemovedEvent
| FormFieldActivatedEvent
| SessionCreatedEvent
| SessionRenamedEvent
| SessionDeletedEvent
| SessionActivatedEvent;
export type EngineEventType = EngineEvent["type"];
export type EngineEventOf<T extends EngineEventType> = Extract<EngineEvent, { type: T }>;

View File

@@ -1,62 +0,0 @@
/**
* Engine composition root.
*
* `createEngine()` wires in-memory repos to the services and shares a single
* event bus. The app layer holds the returned `Engine` instance and passes
* its services into the UI.
*
* Swapping the repository implementation later (ADR-0005) is a matter of
* replacing `createInMemoryRepos()` here. The service signatures don't
* change.
*/
import { createEventBus, type EventBus } from "./events";
import {
createInMemoryRepos,
type InMemoryRepos,
} from "./repos";
import {
createAnnotationService,
createDocumentService,
createEvidenceService,
type AnnotationService,
type DocumentService,
type EvidenceService,
} from "./services";
export * from "./events";
export * from "./repos";
export * from "./services";
export * from "./rendering";
export {
SNAPSHOT_VERSION,
attachPersister,
captureSnapshot,
documentIdsIn,
restoreFromStorage,
restoreSnapshot,
sanitizeDocumentForPersistence,
type EngineSnapshot,
type PersisterOptions,
} from "./persistence";
export interface Engine {
readonly bus: EventBus;
readonly repos: InMemoryRepos;
readonly documents: DocumentService;
readonly annotations: AnnotationService;
readonly evidence: EvidenceService;
}
export function createEngine(): Engine {
const bus = createEventBus();
const repos = createInMemoryRepos();
const documents = createDocumentService(repos.documents, repos.representations, bus);
const annotations = createAnnotationService(repos.annotations, bus);
const evidence = createEvidenceService(
repos.evidenceItems,
(id) => repos.annotations.get(id),
bus,
);
return { bus, repos, documents, annotations, evidence };
}

View File

@@ -1,209 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import {
attachPersister,
captureSnapshot,
createEngine,
restoreFromStorage,
restoreSnapshot,
sanitizeDocumentForPersistence,
type Engine,
type EngineEvent,
type EngineSnapshot,
} from "./index";
function fakeDocAndRep(suffix: string): {
document: Document;
representation: DocumentRepresentation;
} {
const docId = `doc_${suffix}` as DocumentId;
const repId = `rep_${suffix}` as RepresentationId;
return {
document: {
id: docId,
mediaType: "application/pdf",
title: `Doc ${suffix}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: docId,
representationType: "pdf-text",
contentHash: `hash-${suffix}`,
canonicalText: "The quick brown fox.",
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
};
}
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function seed(engine: Engine, suffix: string) {
const { document, representation } = fakeDocAndRep(suffix);
engine.documents.register({ document, representation });
const ann = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
quote: "brown fox",
});
const item = engine.evidence.create({
annotationIds: [ann.id],
commentary: `commentary-${suffix}`,
});
return { document, representation, ann, item };
}
describe("captureSnapshot + restoreSnapshot", () => {
it("round-trips documents, representations, annotations and evidence items", () => {
const src = createEngine();
const a = seed(src, "a");
const b = seed(src, "b");
const snap = captureSnapshot(src);
expect(snap.documents).toHaveLength(2);
expect(snap.representations).toHaveLength(2);
expect(snap.annotations).toHaveLength(2);
expect(snap.evidenceItems).toHaveLength(2);
const dst = createEngine();
restoreSnapshot(dst, snap);
expect(dst.documents.get(a.document.id)?.title).toBe("Doc a");
expect(dst.documents.get(b.document.id)?.title).toBe("Doc b");
expect(dst.annotations.get(a.ann.id)?.quote).toBe("brown fox");
expect(dst.evidence.get(a.item.id)?.commentary).toBe("commentary-a");
});
it("restoreSnapshot does NOT emit *Created events (events would loop the persister)", () => {
const src = createEngine();
seed(src, "x");
const snap = captureSnapshot(src);
const dst = createEngine();
const seen: EngineEvent["type"][] = [];
dst.bus.onAny((e) => seen.push(e.type));
restoreSnapshot(dst, snap);
expect(seen).toEqual([]);
});
it("rejects a snapshot with a mismatching version", () => {
const dst = createEngine();
expect(() =>
restoreSnapshot(dst, {
version: 999,
documents: [],
representations: [],
annotations: [],
evidenceItems: [],
} as EngineSnapshot),
).toThrow(/version/);
});
});
describe("attachPersister", () => {
let storage: ReturnType<typeof memoryStorage>;
let engine: Engine;
const KEY = "ce-test-snap";
beforeEach(() => {
storage = memoryStorage();
engine = createEngine();
});
it("writes a snapshot to storage on every mutating event", () => {
const off = attachPersister(engine, { key: KEY, storage });
expect(storage.getItem(KEY)).toBeNull();
seed(engine, "z");
const raw = storage.getItem(KEY);
expect(raw).not.toBeNull();
const snap = JSON.parse(raw!) as EngineSnapshot;
expect(snap.documents).toHaveLength(1);
expect(snap.evidenceItems).toHaveLength(1);
off();
});
it("stops writing after the unsubscribe is called", () => {
const off = attachPersister(engine, { key: KEY, storage });
seed(engine, "q");
const after = storage.getItem(KEY);
off();
seed(engine, "r");
expect(storage.getItem(KEY)).toBe(after);
});
it("survives a JSON.stringify failure without throwing into the caller", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const failing = { ...memoryStorage(), setItem: () => { throw new Error("boom"); } };
attachPersister(engine, { key: KEY, storage: failing });
expect(() => seed(engine, "k")).not.toThrow();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
describe("restoreFromStorage", () => {
it("returns {restored: false} when the key is empty", () => {
const storage = memoryStorage();
const engine = createEngine();
const result = restoreFromStorage(engine, { key: "missing", storage });
expect(result.restored).toBe(false);
});
it("hydrates the engine when storage holds a valid snapshot", () => {
const src = createEngine();
seed(src, "rs");
const storage = memoryStorage();
storage.setItem("snap", JSON.stringify(captureSnapshot(src)));
const dst = createEngine();
const result = restoreFromStorage(dst, { key: "snap", storage });
expect(result.restored).toBe(true);
expect(dst.documents.list()).toHaveLength(1);
});
it("strips blob: URIs from persisted documents", () => {
const engine = createEngine();
const docId = "doc_blob" as DocumentId;
engine.documents.register({
document: {
id: docId,
mediaType: "application/pdf",
title: "upload.pdf",
uri: "blob:http://localhost/dead",
createdAt: "2026-06-07T00:00:00.000Z",
updatedAt: "2026-06-07T00:00:00.000Z",
},
representation: fakeDocAndRep("blob").representation,
});
const snap = captureSnapshot(engine);
expect(snap.documents[0]?.uri).toBeUndefined();
expect(sanitizeDocumentForPersistence({
id: docId,
mediaType: "application/pdf",
uri: "blob:x",
createdAt: "x",
updatedAt: "x",
}).uri).toBeUndefined();
});
it("ignores malformed JSON without throwing", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const storage = memoryStorage();
storage.setItem("snap", "not-json");
const engine = createEngine();
const result = restoreFromStorage(engine, { key: "snap", storage });
expect(result.restored).toBe(false);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@@ -1,145 +0,0 @@
/**
* Engine snapshot + restore.
*
* MVP "persistence" — capture the engine's in-memory state into a JSON blob
* and restore it later. Used by the SPA to survive page reloads via
* `localStorage` until ADR-0005 lands a real store.
*
* Restore deliberately bypasses the service layer: it writes directly to
* the repos so no `*Created` events fire. Without that, restoring would
* trigger the persister to re-write the same snapshot — and if the user
* has another tab open, it would also broadcast spurious "this annotation
* just appeared" events to UI listeners.
*/
import type { Annotation } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type { DocumentId } from "@shared/ids";
import type { Engine } from "./index";
export const SNAPSHOT_VERSION = 1;
export interface EngineSnapshot {
readonly version: number;
readonly documents: readonly Document[];
readonly representations: readonly DocumentRepresentation[];
readonly annotations: readonly Annotation[];
readonly evidenceItems: readonly EvidenceItem[];
}
/** Strip ephemeral blob URLs — they cannot survive reload without bytes. */
export function sanitizeDocumentForPersistence(document: Document): Document {
if (!document.uri || !document.uri.startsWith("blob:")) return document;
const { uri: _uri, ...rest } = document;
return rest;
}
export function captureSnapshot(engine: Engine): EngineSnapshot {
const documents = engine.documents.list().map(sanitizeDocumentForPersistence);
// Gather representations per known document.
const representations: DocumentRepresentation[] = [];
const annotations: Annotation[] = [];
const evidenceItems: EvidenceItem[] = [];
const seenItemIds = new Set<string>();
for (const doc of documents) {
representations.push(...engine.documents.listRepresentations(doc.id));
annotations.push(...engine.annotations.listByDocument(doc.id));
for (const item of engine.evidence.listByDocument(doc.id)) {
// listByDocument keys off annotation lookup; an item that shares
// annotations across two documents would surface twice. De-dupe.
if (!seenItemIds.has(item.id)) {
seenItemIds.add(item.id);
evidenceItems.push(item);
}
}
}
return {
version: SNAPSHOT_VERSION,
documents: [...documents],
representations,
annotations,
evidenceItems,
};
}
export function restoreSnapshot(engine: Engine, snapshot: EngineSnapshot): void {
if (snapshot.version !== SNAPSHOT_VERSION) {
throw new Error(
`restoreSnapshot: snapshot version ${snapshot.version} does not match current ${SNAPSHOT_VERSION}`,
);
}
for (const d of snapshot.documents) engine.repos.documents.create(d);
for (const r of snapshot.representations) engine.repos.representations.create(r);
for (const a of snapshot.annotations) engine.repos.annotations.create(a);
for (const i of snapshot.evidenceItems) engine.repos.evidenceItems.create(i);
}
export interface PersisterOptions {
/** Storage key. */
readonly key: string;
/** Storage shim — defaults to globalThis.localStorage. */
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}
/**
* Subscribe to engine events and write a fresh snapshot on every mutation.
* Returns the unsubscribe function.
*
* Initial snapshot is NOT written — call `captureSnapshot` + `storage.setItem`
* yourself if you want a baseline.
*/
export function attachPersister(engine: Engine, options: PersisterOptions): () => void {
const storage = options.storage ?? globalThis.localStorage;
const write = () => {
const snap = captureSnapshot(engine);
try {
storage.setItem(options.key, JSON.stringify(snap));
} catch (err) {
// localStorage quota / serialization errors shouldn't crash the app.
// Surface to the console; ADR-0005 owns the durable fix.
console.warn("attachPersister: write failed", err);
}
};
const offs = [
engine.bus.on("DocumentImported", write),
engine.bus.on("DocumentRepresentationGenerated", write),
engine.bus.on("AnnotationCreated", write),
engine.bus.on("AnnotationResolved", write),
engine.bus.on("AnnotationResolutionFailed", write),
engine.bus.on("EvidenceItemCreated", write),
engine.bus.on("EvidenceItemUpdated", write),
];
return () => {
for (const off of offs) off();
};
}
export type RestoreFromStorageOptions = PersisterOptions;
export function restoreFromStorage(
engine: Engine,
options: RestoreFromStorageOptions,
): { readonly restored: boolean; readonly snapshot?: EngineSnapshot } {
const storage = options.storage ?? globalThis.localStorage;
const raw = storage.getItem(options.key);
if (!raw) return { restored: false };
try {
const parsed = JSON.parse(raw) as EngineSnapshot;
if (typeof parsed !== "object" || parsed === null) return { restored: false };
restoreSnapshot(engine, parsed);
return { restored: true, snapshot: parsed };
} catch (err) {
console.warn("restoreFromStorage: parse failed, ignoring stored snapshot", err);
return { restored: false };
}
}
/**
* Narrow helper: get the set of document ids restored from a snapshot.
* Useful for the SPA's "show me what was open last time" logic.
*/
export function documentIdsIn(snapshot: EngineSnapshot): readonly DocumentId[] {
return snapshot.documents.map((d) => d.id);
}

View File

@@ -1,172 +0,0 @@
/**
* HTML citation card renderer tests (CE-WP-0004-T03).
*
* Snapshots lock the output format defined in
* `docs/decisions/ADR-0007-citation-card-format.md`. Class names are
* part of the public contract — renaming any of them requires updating
* both this test and the ADR.
*/
import { describe, expect, it } from "vitest";
import type { Annotation } from "@shared/annotation";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
import { renderCitationCardHtml } from "./html";
const DOC_ID = "doc_2024-order" as DocumentId;
const REP_ID = "rep_2024-order" as RepresentationId;
const ANN_ID = "ann_para3" as AnnotationId;
const EV_ID = "ev_para3" as EvidenceItemId;
function makeDoc(overrides: Partial<Document> = {}): Document {
return {
id: DOC_ID,
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
return {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
quote: "Die Frist endet am 31. März 2024.",
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
return {
id: EV_ID,
annotationIds: [ANN_ID],
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
describe("renderCitationCardHtml()", () => {
it("renders the full aside with quote, attribution, and commentary", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&amp;annotation=ann_para3">Open source</a></p>
<div class="citation-card__commentary">Deadline clause for the buyer.</div>
</aside>
"
`);
});
it("omits the commentary div when none is set", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&amp;annotation=ann_para3">Open source</a></p>
</aside>
"
`);
});
it("converts \\n inside the quote to <br> for rich-text paste", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn({ quote: "Line one.\nLine two." }),
});
expect(out).toContain(
'<blockquote class="citation-card__quote">Line one.<br>Line two.</blockquote>',
);
});
it("HTML-escapes &, <, >, \", and ' in user-supplied text", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({
commentary: `Notes: <script>alert("xss")</script> & 'untrusted'`,
}),
document: makeDoc({ title: `Order "draft" & more` }),
annotation: makeAnn({ quote: "<<value>> & 'inner'" }),
});
// Quote escaping
expect(out).toContain(
"<blockquote class=\"citation-card__quote\">&lt;&lt;value&gt;&gt; &amp; &#39;inner&#39;</blockquote>",
);
// Source label escaping
expect(out).toContain(
"<cite class=\"citation-card__source\">Order &quot;draft&quot; &amp; more</cite>",
);
// Commentary escaping — and especially: no raw <script>
expect(out).not.toContain("<script>");
expect(out).toContain(
"<div class=\"citation-card__commentary\">Notes: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; &amp; &#39;untrusted&#39;</div>",
);
});
it("falls back to metadata.filename when document.title is missing", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: {
id: DOC_ID,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
metadata: { filename: "order-2024.pdf" },
},
annotation: makeAnn(),
});
expect(out).toContain(
'<cite class="citation-card__source">order-2024.pdf</cite>',
);
});
it("drops the · separator and link when openContextUrlOverride is empty", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "",
});
expect(out).toMatchInlineSnapshot(`
"<aside class="citation-card">
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite></p>
</aside>
"
`);
});
it("does not emit inline style attributes (host page controls CSS)", () => {
const out = renderCitationCardHtml({
evidenceItem: makeEv({ commentary: "Some commentary." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).not.toMatch(/style=/);
});
});

View File

@@ -1,92 +0,0 @@
/**
* HTML citation card renderer.
*
* Implements CE-WP-0004-T03. Output format and rules are locked in
* `docs/decisions/ADR-0007-citation-card-format.md`.
*
* The output is a single `<aside class="citation-card">` element with no
* inline styles — host pages provide the CSS. Commentary is treated as
* plain text; no Markdown or raw HTML passthrough.
*/
import type { Annotation } from "@shared/annotation";
import { resolveSourceLabel } from "@shared/citation-card-source";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import { openContextUrl } from "@shared/open-context-url";
export interface RenderCitationCardHtmlInput {
readonly evidenceItem: EvidenceItem;
readonly document: Document;
readonly annotation: Annotation;
/**
* Override the deep-link URL — same semantics as
* `renderCitationCardMarkdown` (host pages with a non-default mount
* prefix). Defaults to `openContextUrl({…})`.
*/
readonly openContextUrlOverride?: string;
}
const HTML_ENTITIES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (c) => HTML_ENTITIES[c]!);
}
/**
* Convert source line breaks to `<br>` inside the blockquote so a
* multi-line quote stays multi-line in rich-text paste targets. Each
* line is HTML-escaped first.
*/
function escapeQuoteWithLineBreaks(quote: string): string {
return quote.split("\n").map(escapeHtml).join("<br>");
}
export function renderCitationCardHtml({
evidenceItem,
document,
annotation,
openContextUrlOverride,
}: RenderCitationCardHtmlInput): string {
const quote = annotation.quote ?? "";
const sourceLabel = resolveSourceLabel(document);
const url =
openContextUrlOverride !== undefined
? openContextUrlOverride
: openContextUrl({
documentId: document.id,
annotationId: annotation.id,
});
const quoteHtml = escapeQuoteWithLineBreaks(quote);
const sourceHtml = escapeHtml(sourceLabel);
const linkSegment =
url.length > 0
? ` · <a class="citation-card__link" href="${escapeHtml(url)}">Open source</a>`
: "";
const attributionHtml = `<cite class="citation-card__source">${sourceHtml}</cite>${linkSegment}`;
const lines = [
'<aside class="citation-card">',
` <blockquote class="citation-card__quote">${quoteHtml}</blockquote>`,
` <p class="citation-card__attribution">${attributionHtml}</p>`,
];
if (evidenceItem.commentary) {
const commentaryHtml = escapeHtml(evidenceItem.commentary);
lines.push(
` <div class="citation-card__commentary">${commentaryHtml}</div>`,
);
}
lines.push("</aside>");
return lines.join("\n") + "\n";
}

View File

@@ -1,8 +0,0 @@
export {
renderCitationCardMarkdown,
type RenderCitationCardMarkdownInput,
} from "./markdown";
export {
renderCitationCardHtml,
type RenderCitationCardHtmlInput,
} from "./html";

View File

@@ -1,194 +0,0 @@
/**
* Markdown citation card renderer tests (CE-WP-0004-T02).
*
* Snapshots lock the output format defined in
* `docs/decisions/ADR-0007-citation-card-format.md`. If a snapshot
* intentionally changes, update ADR-0007 in the same commit so the
* written contract and the runtime stay in sync.
*/
import { describe, expect, it } from "vitest";
import type { Annotation } from "@shared/annotation";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
import { renderCitationCardMarkdown } from "./markdown";
const DOC_ID = "doc_2024-order" as DocumentId;
const REP_ID = "rep_2024-order" as RepresentationId;
const ANN_ID = "ann_para3" as AnnotationId;
const EV_ID = "ev_para3" as EvidenceItemId;
function makeDoc(overrides: Partial<Document> = {}): Document {
return {
id: DOC_ID,
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
return {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
quote: "Die Frist endet am 31. März 2024.",
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
return {
id: EV_ID,
annotationIds: [ANN_ID],
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...overrides,
};
}
describe("renderCitationCardMarkdown()", () => {
it("renders quote + attribution + commentary", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
Deadline clause for the buyer.
"
`);
});
it("omits the commentary paragraph when none is set", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("preserves multi-line quotes by prefixing each line with '> '", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn({ quote: "Line one.\nLine two.\nLine three." }),
});
expect(out).toMatchInlineSnapshot(`
"> Line one.
> Line two.
> Line three.
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("falls back to metadata.filename when document.title is missing", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: {
id: DOC_ID,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
metadata: { filename: "order-2024.pdf" },
},
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *order-2024.pdf* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("escapes * and _ in the source label to keep italics intact", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc({ title: "Order *2024_v2*" }),
annotation: makeAnn(),
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order \\*2024\\_v2\\** · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
it("omits the open-context link when override is empty", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "",
});
expect(out).toMatchInlineSnapshot(`
"> Die Frist endet am 31. März 2024.
— *Order from 14 Mar 2024*
"
`);
});
it("honours an openContextUrlOverride for mounted-prefix hosts", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: makeAnn(),
openContextUrlOverride: "https://citations.example.test/v?d=foo&a=bar",
});
expect(out).toContain(
"[Open source](https://citations.example.test/v?d=foo&a=bar)",
);
});
it("renders an empty blockquote line when the annotation has no quote", () => {
const out = renderCitationCardMarkdown({
evidenceItem: makeEv(),
document: makeDoc(),
annotation: {
id: ANN_ID,
documentId: DOC_ID,
representationId: REP_ID,
selectors: [],
normalizeVersion: 1,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
});
expect(out).toMatchInlineSnapshot(`
">
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
"
`);
});
});

View File

@@ -1,68 +0,0 @@
/**
* Markdown citation card renderer.
*
* Implements CE-WP-0004-T02. Output format and rules are locked in
* `docs/decisions/ADR-0007-citation-card-format.md`.
*
* Inputs are passed by value (not looked up from a service) so this
* renderer can be unit-tested without an engine and reused by future
* batch-export jobs.
*/
import type { Annotation } from "@shared/annotation";
import { resolveSourceLabel } from "@shared/citation-card-source";
import type { Document } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import { openContextUrl } from "@shared/open-context-url";
export interface RenderCitationCardMarkdownInput {
readonly evidenceItem: EvidenceItem;
readonly document: Document;
readonly annotation: Annotation;
/**
* Override the deep-link URL — useful for hosts that mount the viewer
* under a non-default prefix. Defaults to `openContextUrl({…})`.
*/
readonly openContextUrlOverride?: string;
}
/**
* Escape `*` and `_` inside the source label so it doesn't accidentally
* break out of the surrounding `*…*` italic span. No other escaping is
* performed — citations should reproduce the source text verbatim.
*/
function escapeSourceLabel(label: string): string {
return label.replace(/[*_]/g, (m) => `\\${m}`);
}
export function renderCitationCardMarkdown({
evidenceItem,
document,
annotation,
openContextUrlOverride,
}: RenderCitationCardMarkdownInput): string {
const quote = annotation.quote ?? "";
const sourceLabel = resolveSourceLabel(document);
const url =
openContextUrlOverride !== undefined
? openContextUrlOverride
: openContextUrl({
documentId: document.id,
annotationId: annotation.id,
});
const quoteLines = quote.length === 0 ? [""] : quote.split("\n");
// Trailing whitespace on a blockquote line can be misread as a hard
// line break (`<br>`) by some Markdown engines; trimEnd keeps an
// empty line as bare `>`.
const quoteBlock = quoteLines.map((line) => `> ${line}`.trimEnd()).join("\n");
const attributionLink = url.length > 0 ? ` · [Open source](${url})` : "";
const attribution = `— *${escapeSourceLabel(sourceLabel)}*${attributionLink}`;
const sections = [quoteBlock, attribution];
if (evidenceItem.commentary) sections.push(evidenceItem.commentary);
return sections.join("\n\n") + "\n";
}

View File

@@ -1,47 +0,0 @@
/**
* In-memory `Map`-backed `SessionRepository`.
*
* Sister to `in-memory.ts` but for `Session` objects. The session repo
* lives *outside* the per-session `Engine` (one repo for the whole app;
* each session's engine snapshot is keyed by `session.id`). Keeping it
* in the same `engine/repos` directory keeps the storage layer
* conventions together so the eventual ADR-0005 swap touches one
* neighbourhood.
*/
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
export interface SessionRepository {
create(session: Session): Session;
get(id: SessionId): Session | null;
list(): readonly Session[];
update(session: Session): Session;
delete(id: SessionId): boolean;
}
export function createInMemorySessionRepository(): SessionRepository {
const sessions = new Map<SessionId, Session>();
return {
create(session) {
sessions.set(session.id, session);
return session;
},
get(id) {
return sessions.get(id) ?? null;
},
list() {
return [...sessions.values()];
},
update(session) {
if (!sessions.has(session.id)) {
throw new Error(`SessionRepository.update: unknown id ${session.id}`);
}
sessions.set(session.id, session);
return session;
},
delete(id) {
return sessions.delete(id);
},
};
}

View File

@@ -1,166 +0,0 @@
/**
* In-memory `Map`-backed repositories.
*
* Implements the MVP storage layer. The repository interfaces match the
* shape that ADR-0005's eventual persistence implementation will satisfy,
* so swapping `createInMemoryRepos()` for a SQLite/Postgres factory later
* is a localised change.
*
* All mutating methods return the *stored* object so callers can pick up
* server-assigned fields (none in MVP, but the contract anticipates it).
*/
import type { Annotation } from "@shared/annotation";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { EvidenceItem } from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
RepresentationId,
} from "@shared/ids";
export interface DocumentRepository {
create(document: Document): Document;
get(id: DocumentId): Document | null;
list(): readonly Document[];
update(document: Document): Document;
delete(id: DocumentId): boolean;
}
export interface RepresentationRepository {
create(representation: DocumentRepresentation): DocumentRepresentation;
get(id: RepresentationId): DocumentRepresentation | null;
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
deleteByDocument(documentId: DocumentId): number;
}
export interface AnnotationRepository {
create(annotation: Annotation): Annotation;
get(id: AnnotationId): Annotation | null;
listByDocument(documentId: DocumentId): readonly Annotation[];
update(annotation: Annotation): Annotation;
}
export interface EvidenceItemRepository {
create(item: EvidenceItem): EvidenceItem;
get(id: EvidenceItemId): EvidenceItem | null;
listByDocument(
documentId: DocumentId,
annotationLookup: (id: AnnotationId) => Annotation | null,
): readonly EvidenceItem[];
update(item: EvidenceItem): EvidenceItem;
}
export interface InMemoryRepos {
readonly documents: DocumentRepository;
readonly representations: RepresentationRepository;
readonly annotations: AnnotationRepository;
readonly evidenceItems: EvidenceItemRepository;
}
export function createInMemoryRepos(): InMemoryRepos {
const documents = new Map<DocumentId, Document>();
const representations = new Map<RepresentationId, DocumentRepresentation>();
const annotations = new Map<AnnotationId, Annotation>();
const evidenceItems = new Map<EvidenceItemId, EvidenceItem>();
return {
documents: {
create(document) {
documents.set(document.id, document);
return document;
},
get(id) {
return documents.get(id) ?? null;
},
list() {
return [...documents.values()];
},
update(document) {
if (!documents.has(document.id)) {
throw new Error(`DocumentRepository.update: unknown id ${document.id}`);
}
documents.set(document.id, document);
return document;
},
delete(id) {
return documents.delete(id);
},
},
representations: {
create(representation) {
representations.set(representation.id, representation);
return representation;
},
get(id) {
return representations.get(id) ?? null;
},
listByDocument(documentId) {
const out: DocumentRepresentation[] = [];
for (const rep of representations.values()) {
if (rep.documentId === documentId) out.push(rep);
}
return out;
},
deleteByDocument(documentId) {
let removed = 0;
for (const [id, rep] of representations) {
if (rep.documentId === documentId) {
representations.delete(id);
removed += 1;
}
}
return removed;
},
},
annotations: {
create(annotation) {
annotations.set(annotation.id, annotation);
return annotation;
},
get(id) {
return annotations.get(id) ?? null;
},
listByDocument(documentId) {
const out: Annotation[] = [];
for (const ann of annotations.values()) {
if (ann.documentId === documentId) out.push(ann);
}
return out;
},
update(annotation) {
if (!annotations.has(annotation.id)) {
throw new Error(`AnnotationRepository.update: unknown id ${annotation.id}`);
}
annotations.set(annotation.id, annotation);
return annotation;
},
},
evidenceItems: {
create(item) {
evidenceItems.set(item.id, item);
return item;
},
get(id) {
return evidenceItems.get(id) ?? null;
},
listByDocument(documentId, annotationLookup) {
const out: EvidenceItem[] = [];
for (const item of evidenceItems.values()) {
if (item.annotationIds.some((aid) => annotationLookup(aid)?.documentId === documentId)) {
out.push(item);
}
}
return out;
},
update(item) {
if (!evidenceItems.has(item.id)) {
throw new Error(`EvidenceItemRepository.update: unknown id ${item.id}`);
}
evidenceItems.set(item.id, item);
return item;
},
},
};
}

View File

@@ -1,12 +0,0 @@
export {
createInMemoryRepos,
type InMemoryRepos,
type DocumentRepository,
type RepresentationRepository,
type AnnotationRepository,
type EvidenceItemRepository,
} from "./in-memory";
export {
createInMemorySessionRepository,
type SessionRepository,
} from "./in-memory-sessions";

View File

@@ -1,129 +0,0 @@
/**
* Annotation service — creates technical marks on document ranges and
* emits `AnnotationCreated`. Resolution-status updates emit
* `AnnotationResolved` / `AnnotationResolutionFailed`.
*
* Annotation creation is the engine's response to a user action in the
* viewer (T07). The viewer adapter has already turned the selection into
* `Selector[]`; this service stamps an ID, normalize-version, timestamps,
* persists, and broadcasts.
*/
import type {
Annotation,
AnnotationResolutionStatus,
} from "@shared/annotation";
import type { DocumentId, RepresentationId, AnnotationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import { newId } from "@shared/ids";
import { NORMALIZE_VERSION } from "@shared/text/normalize";
import type { EventBus } from "../events";
import type { AnnotationRepository } from "../repos";
export interface CreateAnnotationInput {
readonly documentId: DocumentId;
readonly representationId?: RepresentationId;
readonly selectors: readonly Selector[];
readonly quote?: string;
readonly note?: string;
readonly createdBy?: string;
}
export interface AnnotationService {
create(input: CreateAnnotationInput): Annotation;
get(id: AnnotationId): Annotation | null;
listByDocument(documentId: DocumentId): readonly Annotation[];
setResolutionStatus(
id: AnnotationId,
status: AnnotationResolutionStatus,
opts: { readonly confidence: number; readonly reason?: string },
): Annotation;
/**
* Edit the human-facing `quote` text on an annotation without touching
* the underlying selectors. Selectors stay the source of truth for
* locating the passage; the quote is the user's editable display copy.
*/
updateQuote(id: AnnotationId, quote: string): Annotation;
}
export function createAnnotationService(
annotations: AnnotationRepository,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): AnnotationService {
return {
create(input) {
const ts = now();
const annotation: Annotation = {
id: newId("annotation"),
documentId: input.documentId,
...(input.representationId !== undefined ? { representationId: input.representationId } : {}),
selectors: input.selectors,
...(input.quote !== undefined ? { quote: input.quote } : {}),
...(input.note !== undefined ? { note: input.note } : {}),
normalizeVersion: NORMALIZE_VERSION,
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = annotations.create(annotation);
bus.emit({ type: "AnnotationCreated", annotationId: stored.id, annotation: stored });
return stored;
},
get(id) {
return annotations.get(id);
},
listByDocument(documentId) {
return annotations.listByDocument(documentId);
},
setResolutionStatus(id, status, opts) {
const existing = annotations.get(id);
if (!existing) {
throw new Error(`AnnotationService.setResolutionStatus: unknown id ${id}`);
}
const updated: Annotation = {
...existing,
resolutionStatus: status,
updatedAt: now(),
};
const stored = annotations.update(updated);
if (status === "unresolved" || status === "stale") {
bus.emit({
type: "AnnotationResolutionFailed",
annotationId: stored.id,
reason: opts.reason ?? status,
});
} else {
bus.emit({
type: "AnnotationResolved",
annotationId: stored.id,
status,
confidence: opts.confidence,
});
}
return stored;
},
updateQuote(id, quote) {
const existing = annotations.get(id);
if (!existing) {
throw new Error(`AnnotationService.updateQuote: unknown id ${id}`);
}
const trimmed = quote.length === 0 ? undefined : quote;
const updated: Annotation = {
...existing,
// exactOptionalPropertyTypes: drop `quote` when empty rather
// than setting it to undefined.
...(trimmed !== undefined ? { quote: trimmed } : {}),
updatedAt: now(),
};
if (trimmed === undefined && "quote" in updated) {
// Remove the field outright when clearing.
delete (updated as { quote?: string }).quote;
}
const stored = annotations.update(updated);
bus.emit({ type: "AnnotationUpdated", annotationId: stored.id, annotation: stored });
return stored;
},
};
}

View File

@@ -1,74 +0,0 @@
/**
* Document service — registers ingested documents and emits the §4 events.
*
* The ingest pipeline (`src/source/pdf/ingest.ts`) is a pure function over
* bytes — it does not touch the engine. The app composition root calls
* `ingestPdf` then hands the result to `documentService.register()`, which
* is where the engine takes over: persist into the repos, emit
* `DocumentImported` + `DocumentRepresentationGenerated`.
*/
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId } from "@shared/ids";
import type { EventBus } from "../events";
import type { DocumentRepository, RepresentationRepository } from "../repos";
export interface DocumentService {
register(input: {
readonly document: Document;
readonly representation: DocumentRepresentation;
}): { readonly document: Document; readonly representation: DocumentRepresentation };
get(id: DocumentId): Document | null;
list(): readonly Document[];
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
remove(id: DocumentId): boolean;
}
export function createDocumentService(
documents: DocumentRepository,
representations: RepresentationRepository,
bus: EventBus,
): DocumentService {
return {
register({ document, representation }) {
const storedDocument = documents.create(document);
const storedRepresentation = representations.create(representation);
bus.emit({
type: "DocumentImported",
documentId: storedDocument.id,
document: storedDocument,
});
bus.emit({
type: "DocumentRepresentationGenerated",
documentId: storedDocument.id,
representationId: storedRepresentation.id,
representation: storedRepresentation,
});
return { document: storedDocument, representation: storedRepresentation };
},
get(id) {
return documents.get(id);
},
list() {
return documents.list();
},
getRepresentation(id) {
return representations.get(id);
},
listRepresentations(documentId) {
return representations.listByDocument(documentId);
},
remove(id) {
const existing = documents.get(id);
if (!existing) return false;
representations.deleteByDocument(id);
const removed = documents.delete(id);
if (removed) {
bus.emit({ type: "DocumentRemoved", documentId: id });
}
return removed;
},
};
}

View File

@@ -1,142 +0,0 @@
/**
* Evidence service — creates EvidenceItems on top of annotations and
* tracks their lifecycle. Emits §4 events: `EvidenceItemCreated`,
* `EvidenceItemUpdated`, `EvidenceItemActivated`.
*
* MVP item shape per `wiki/SharedContracts.md` §2.2: status starts at
* `candidate`, may transition to `confirmed | rejected | needs-check`.
* Item-level relation/strength (supports/contradicts/...) lives on the
* link, not the item — that's CE-WP-0003.
*/
import type { Annotation } from "@shared/annotation";
import type {
EvidenceItem,
EvidenceItemStatus,
} from "@shared/evidence";
import type {
AnnotationId,
DocumentId,
EvidenceItemId,
} from "@shared/ids";
import { newId } from "@shared/ids";
import type { EventBus, EvidenceItemActivatedEvent } from "../events";
import type { EvidenceItemRepository } from "../repos";
export interface CreateEvidenceItemInput {
readonly annotationIds: readonly AnnotationId[];
readonly title?: string;
readonly commentary?: string;
readonly status?: EvidenceItemStatus;
readonly confidence?: number;
readonly tags?: readonly string[];
readonly createdBy?: string;
}
export interface EvidenceService {
create(input: CreateEvidenceItemInput): EvidenceItem;
get(id: EvidenceItemId): EvidenceItem | null;
listByDocument(documentId: DocumentId): readonly EvidenceItem[];
setStatus(id: EvidenceItemId, status: EvidenceItemStatus): EvidenceItem;
updateCommentary(id: EvidenceItemId, commentary: string): EvidenceItem;
activate(
id: EvidenceItemId,
source?: EvidenceItemActivatedEvent["source"],
): EvidenceItem;
/**
* Reverse lookup: find the evidence item that owns a given annotation.
* Used by the viewer's click-on-highlight handler so a click on the
* passage activates the right sidebar row.
*/
findByAnnotationId(
documentId: DocumentId,
annotationId: AnnotationId,
): EvidenceItem | null;
}
export function createEvidenceService(
items: EvidenceItemRepository,
annotationLookup: (id: AnnotationId) => Annotation | null,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): EvidenceService {
return {
create(input) {
if (input.annotationIds.length === 0) {
throw new Error("EvidenceService.create: at least one annotationId is required");
}
const ts = now();
const item: EvidenceItem = {
id: newId("evidence"),
annotationIds: input.annotationIds,
...(input.title !== undefined ? { title: input.title } : {}),
...(input.commentary !== undefined ? { commentary: input.commentary } : {}),
status: input.status ?? "candidate",
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = items.create(item);
bus.emit({ type: "EvidenceItemCreated", evidenceItemId: stored.id, evidenceItem: stored });
return stored;
},
get(id) {
return items.get(id);
},
listByDocument(documentId) {
return items.listByDocument(documentId, annotationLookup);
},
setStatus(id, status) {
const existing = items.get(id);
if (!existing) {
throw new Error(`EvidenceService.setStatus: unknown id ${id}`);
}
if (existing.status === status) return existing;
const updated: EvidenceItem = { ...existing, status, updatedAt: now() };
const stored = items.update(updated);
bus.emit({
type: "EvidenceItemUpdated",
evidenceItemId: stored.id,
evidenceItem: stored,
previousStatus: existing.status,
});
return stored;
},
updateCommentary(id, commentary) {
const existing = items.get(id);
if (!existing) {
throw new Error(`EvidenceService.updateCommentary: unknown id ${id}`);
}
const updated: EvidenceItem = { ...existing, commentary, updatedAt: now() };
const stored = items.update(updated);
bus.emit({
type: "EvidenceItemUpdated",
evidenceItemId: stored.id,
evidenceItem: stored,
previousStatus: existing.status,
});
return stored;
},
activate(id, source) {
const existing = items.get(id);
if (!existing) {
throw new Error(`EvidenceService.activate: unknown id ${id}`);
}
bus.emit({
type: "EvidenceItemActivated",
evidenceItemId: existing.id,
...(source !== undefined ? { source } : {}),
});
return existing;
},
findByAnnotationId(documentId, annotationId) {
for (const item of items.listByDocument(documentId, annotationLookup)) {
if (item.annotationIds.includes(annotationId)) return item;
}
return null;
},
};
}

View File

@@ -1,29 +0,0 @@
export {
createDocumentService,
type DocumentService,
} from "./documents";
export {
createAnnotationService,
type AnnotationService,
type CreateAnnotationInput,
} from "./annotations";
export {
createEvidenceService,
type EvidenceService,
type CreateEvidenceItemInput,
} from "./evidence";
export {
ACTIVE_SESSION_KEY,
attachSessionPersister,
clearAllSessionData,
computeNextDefaultName,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,
restoreSessionsFromStorage,
SESSIONS_INDEX_KEY,
type CreateSessionInput,
type RestoreSessionsResult,
type SessionPersisterOptions,
type SessionService,
} from "./sessions";

View File

@@ -1,250 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import { createEventBus, type EventBus, type EngineEvent } from "../events";
import {
createInMemorySessionRepository,
type SessionRepository,
} from "../repos";
import {
ACTIVE_SESSION_KEY,
attachSessionPersister,
computeNextDefaultName,
createSessionService,
DuplicateSessionNameError,
engineSnapshotKey,
restoreSessionsFromStorage,
SESSIONS_INDEX_KEY,
type SessionService,
} from "./sessions";
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function freshService(): {
service: SessionService;
repo: SessionRepository;
bus: EventBus;
events: EngineEvent[];
} {
const repo = createInMemorySessionRepository();
const bus = createEventBus();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
const service = createSessionService(repo, bus);
return { service, repo, bus, events };
}
describe("engineSnapshotKey", () => {
it("formats as citation-evidence:session:<id>:engine-snapshot:v1", () => {
expect(engineSnapshotKey("sess_abc" as SessionId)).toBe(
"citation-evidence:session:sess_abc:engine-snapshot:v1",
);
});
});
describe("computeNextDefaultName", () => {
const now = new Date(2026, 4, 27); // 2026-05-27 → "260527"
it("returns 000 for the first session of the day", () => {
expect(computeNextDefaultName([], now)).toBe("260527-session-000");
});
it("counts only sessions whose name matches today's prefix", () => {
const existing = [
{ name: "260527-session-000" } as Session,
{ name: "260527-session-001" } as Session,
{ name: "Random other name" } as Session,
{ name: "260526-session-007" } as Session, // yesterday — ignored
];
expect(computeNextDefaultName(existing, now)).toBe("260527-session-002");
});
it("increments past the highest, not the count", () => {
const existing = [
{ name: "260527-session-005" } as Session,
{ name: "260527-session-002" } as Session,
];
expect(computeNextDefaultName(existing, now)).toBe("260527-session-006");
});
it("ignores names with the wrong shape and accepts trimmed matches", () => {
const existing = [
{ name: " 260527-session-003 " } as Session, // whitespace OK
{ name: "260527-session-12" } as Session, // wrong digit count
{ name: "260527-sess-005" } as Session, // wrong infix
];
expect(computeNextDefaultName(existing, now)).toBe("260527-session-004");
});
});
describe("SessionService.nextDefaultName", () => {
it("delegates to computeNextDefaultName against the current repo", () => {
const s = freshService();
const now = new Date(2026, 4, 27);
s.service.create({ name: "260527-session-000" });
s.service.create({ name: "260527-session-001" });
expect(s.service.nextDefaultName(now)).toBe("260527-session-002");
});
});
describe("SessionService — lifecycle", () => {
let s: ReturnType<typeof freshService>;
beforeEach(() => {
s = freshService();
});
it("creates a session and emits SessionCreated", () => {
const created = s.service.create("Lease 2024");
expect(created.name).toBe("Lease 2024");
expect(created.id).toMatch(/^sess_/);
expect(s.events).toHaveLength(1);
expect(s.events[0]!.type).toBe("SessionCreated");
});
it("trims whitespace in names", () => {
const created = s.service.create(" Trimmed ");
expect(created.name).toBe("Trimmed");
});
it("rejects empty names", () => {
expect(() => s.service.create(" ")).toThrow(/must not be empty/);
});
it("rejects case-insensitive duplicates", () => {
s.service.create("Demo");
expect(() => s.service.create("demo")).toThrow(DuplicateSessionNameError);
expect(() => s.service.create(" Demo ")).toThrow(DuplicateSessionNameError);
});
it("rename emits SessionRenamed with previousName", () => {
const created = s.service.create("Old");
s.events.length = 0;
const renamed = s.service.rename(created.id, "New");
expect(renamed.name).toBe("New");
expect(s.events).toHaveLength(1);
const evt = s.events[0]!;
expect(evt.type).toBe("SessionRenamed");
if (evt.type === "SessionRenamed") {
expect(evt.previousName).toBe("Old");
}
});
it("rename rejects a duplicate (but allows renaming to your own current name)", () => {
const a = s.service.create("Alpha");
s.service.create("Beta");
expect(() => s.service.rename(a.id, "Beta")).toThrow(DuplicateSessionNameError);
// self-rename is fine
const same = s.service.rename(a.id, "Alpha");
expect(same.name).toBe("Alpha");
});
it("delete emits SessionDeleted and clears active if it was the active one", () => {
const created = s.service.create("To Delete");
s.service.setActive(created.id);
s.events.length = 0;
const ok = s.service.delete(created.id);
expect(ok).toBe(true);
const types = s.events.map((e) => e.type);
expect(types).toContain("SessionActivated"); // active cleared first
expect(types).toContain("SessionDeleted");
expect(s.service.getActive()).toBeNull();
});
it("delete on an unknown id is a no-op (returns false, no events)", () => {
const ok = s.service.delete("sess_missing" as SessionId);
expect(ok).toBe(false);
expect(s.events).toHaveLength(0);
});
it("setActive on an unknown id throws", () => {
expect(() => s.service.setActive("sess_nope" as SessionId)).toThrow(/unknown session/);
});
it("setActive emits SessionActivated with previousSessionId", () => {
const a = s.service.create("A");
const b = s.service.create("B");
s.events.length = 0;
s.service.setActive(a.id);
s.service.setActive(b.id);
const activated = s.events.filter((e) => e.type === "SessionActivated");
expect(activated).toHaveLength(2);
if (activated[1]!.type === "SessionActivated") {
expect(activated[1]!.previousSessionId).toBe(a.id);
expect(activated[1]!.sessionId).toBe(b.id);
}
});
it("setActive to the same id is a no-op (no event)", () => {
const a = s.service.create("A");
s.service.setActive(a.id);
s.events.length = 0;
s.service.setActive(a.id);
expect(s.events).toHaveLength(0);
});
it("setActive stamps lastOpenedAt", () => {
const a = s.service.create("A");
expect(a.lastOpenedAt).toBeUndefined();
s.service.setActive(a.id);
const reread = s.service.get(a.id);
expect(reread?.lastOpenedAt).toBeTruthy();
});
});
describe("attachSessionPersister + restoreSessionsFromStorage", () => {
it("round-trips a session index through storage", () => {
const storage = memoryStorage();
const src = freshService();
attachSessionPersister(src.service, src.bus, { storage });
const a = src.service.create("Alpha");
const b = src.service.create("Beta");
src.service.setActive(b.id);
// Read-back into a fresh service.
const dst = freshService();
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
expect(result.restored).toBe(true);
expect(result.sessions.map((s: Session) => s.name).sort()).toEqual(["Alpha", "Beta"]);
expect(result.activeSessionId).toBe(b.id);
expect(dst.service.getActive()).toBe(b.id);
// a is still in the repo too
expect(dst.service.get(a.id)?.name).toBe("Alpha");
});
it("returns {restored:false} when storage is empty", () => {
const storage = memoryStorage();
const dst = freshService();
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
expect(result.restored).toBe(false);
expect(result.sessions).toHaveLength(0);
expect(result.activeSessionId).toBeNull();
});
it("delete clears both the index entry and the per-session snapshot key", () => {
const storage = memoryStorage();
const src = freshService();
attachSessionPersister(src.service, src.bus, { storage });
const created = src.service.create("Doomed");
// Pretend an engine snapshot was written by the per-session persister.
storage.setItem(engineSnapshotKey(created.id), "{}");
src.service.delete(created.id);
expect(storage.getItem(engineSnapshotKey(created.id))).toBeNull();
const raw = storage.getItem(SESSIONS_INDEX_KEY);
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.sessions).toHaveLength(0);
expect(storage.getItem(ACTIVE_SESSION_KEY)).toBeNull();
});
});

View File

@@ -1,362 +0,0 @@
/**
* SessionService — lifecycle for `Session` records.
*
* Lives *above* the per-session `Engine` (the engine itself is recreated
* each time the active session changes). The service owns its own
* `EventBus` instance — independent of any engine bus — but uses the
* same `EngineEvent` shape so consumers can subscribe with the standard
* `bus.on("SessionCreated", …)` pattern.
*
* Per-session engine snapshot persistence is handled by attaching the
* existing `attachPersister(engine, { key: engineSnapshotKey(sessionId) })`
* inside the app's `EngineProvider`. The helpers in this file own the
* *cross-session* storage: the session index + the active-session
* pointer.
*
* Naming rules:
* - Names are trimmed on input.
* - Case-insensitive uniqueness — two sessions cannot coexist with
* names that differ only in case ("Demo" vs "demo"). This avoids
* surprising the ZIP-merge path in T07, which uses `session.name`
* to find an existing target.
*/
import { newId } from "@shared/ids";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import type { EventBus } from "../events";
import type { SessionRepository } from "../repos";
const SESSIONS_INDEX_KEY = "citation-evidence:sessions:v1";
const ACTIVE_SESSION_KEY = "citation-evidence:active-session-id:v1";
export { SESSIONS_INDEX_KEY, ACTIVE_SESSION_KEY };
/**
* Build the engine-snapshot storage key for a given session.
*
* Format: `citation-evidence:session:<sessionId>:engine-snapshot:v1`.
* The `v1` tail leaves room for a future migration that changes the
* snapshot shape without sweeping the legacy keys.
*/
export function engineSnapshotKey(sessionId: SessionId): string {
return `citation-evidence:session:${sessionId}:engine-snapshot:v1`;
}
export class DuplicateSessionNameError extends Error {
constructor(name: string) {
super(`Session with name "${name}" already exists`);
this.name = "DuplicateSessionNameError";
}
}
export interface CreateSessionInput {
readonly name: string;
/** Override the generated id — primarily for tests and importers. */
readonly id?: SessionId;
readonly now?: string;
}
export interface SessionService {
create(input: string | CreateSessionInput): Session;
rename(id: SessionId, name: string): Session;
delete(id: SessionId): boolean;
list(): readonly Session[];
get(id: SessionId): Session | null;
setActive(id: SessionId | null): void;
getActive(): SessionId | null;
/** Record an "I just opened this" timestamp on the session. */
touch(id: SessionId): Session | null;
/**
* Suggest a default session name when the user hasn't typed one.
* Returns `YYMMDD-session-NNN` where `NNN` is the next free counter
* for today (starting at `000`). Pure: does not mutate the repo.
*/
nextDefaultName(now?: Date): string;
}
function nowIso(now?: string): string {
return now ?? new Date().toISOString();
}
function normalisedName(name: string): { display: string; key: string } {
const display = name.trim();
return { display, key: display.toLocaleLowerCase() };
}
export function createSessionService(
repo: SessionRepository,
bus: EventBus,
): SessionService {
let activeId: SessionId | null = null;
function assertUniqueName(name: string, exceptId?: SessionId) {
const { key } = normalisedName(name);
for (const existing of repo.list()) {
if (exceptId && existing.id === exceptId) continue;
if (existing.name.trim().toLocaleLowerCase() === key) {
throw new DuplicateSessionNameError(name);
}
}
}
return {
create(input) {
const { name, id, now } =
typeof input === "string" ? { name: input, id: undefined, now: undefined } : input;
const { display } = normalisedName(name);
if (display.length === 0) {
throw new Error("SessionService.create: name must not be empty");
}
assertUniqueName(display);
const ts = nowIso(now);
const session: Session = {
id: id ?? newId("session"),
name: display,
createdAt: ts,
updatedAt: ts,
};
const stored = repo.create(session);
bus.emit({ type: "SessionCreated", sessionId: stored.id, session: stored });
return stored;
},
rename(id, name) {
const existing = repo.get(id);
if (!existing) {
throw new Error(`SessionService.rename: unknown session ${id}`);
}
const { display } = normalisedName(name);
if (display.length === 0) {
throw new Error("SessionService.rename: name must not be empty");
}
assertUniqueName(display, id);
const previousName = existing.name;
const next: Session = {
...existing,
name: display,
updatedAt: nowIso(),
};
const stored = repo.update(next);
bus.emit({
type: "SessionRenamed",
sessionId: stored.id,
session: stored,
previousName,
});
return stored;
},
delete(id) {
const removed = repo.delete(id);
if (removed) {
if (activeId === id) {
const previousSessionId = activeId;
activeId = null;
bus.emit({
type: "SessionActivated",
sessionId: null,
previousSessionId,
});
}
bus.emit({ type: "SessionDeleted", sessionId: id });
}
return removed;
},
list() {
return repo.list();
},
get(id) {
return repo.get(id);
},
setActive(id) {
if (id !== null && !repo.get(id)) {
throw new Error(`SessionService.setActive: unknown session ${id}`);
}
if (id === activeId) return;
const previousSessionId = activeId;
activeId = id;
if (id) {
const existing = repo.get(id);
if (existing) {
repo.update({ ...existing, lastOpenedAt: nowIso() });
}
}
bus.emit({
type: "SessionActivated",
sessionId: id,
previousSessionId,
});
},
getActive() {
return activeId;
},
touch(id) {
const existing = repo.get(id);
if (!existing) return null;
return repo.update({ ...existing, lastOpenedAt: nowIso() });
},
nextDefaultName(now = new Date()) {
return computeNextDefaultName(repo.list(), now);
},
};
}
/**
* Pure helper exported for tests + callers that want to preview the
* name without going through the service instance. Format:
* `YYMMDD-session-NNN`. `NNN` increments only against today's
* existing sessions; tomorrow's counter starts fresh at `000`.
*/
export function computeNextDefaultName(
existing: readonly Session[],
now: Date = new Date(),
): string {
const yy = String(now.getFullYear() % 100).padStart(2, "0");
const mm = String(now.getMonth() + 1).padStart(2, "0");
const dd = String(now.getDate()).padStart(2, "0");
const prefix = `${yy}${mm}${dd}-session-`;
const re = new RegExp(`^${prefix}(\\d{3})$`);
let max = -1;
for (const s of existing) {
const m = re.exec(s.name.trim());
if (m) {
const n = parseInt(m[1]!, 10);
if (n > max) max = n;
}
}
const next = String(max + 1).padStart(3, "0");
return `${prefix}${next}`;
}
// ---------------------------------------------------------------------------
// Cross-session persistence (the session index + active-session pointer).
// Per-session engine snapshots are handled by `attachPersister` against
// `engineSnapshotKey(sessionId)`.
// ---------------------------------------------------------------------------
export interface SessionPersisterOptions {
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
}
interface SessionsFile {
readonly version: 1;
readonly sessions: readonly Session[];
readonly activeSessionId: SessionId | null;
}
export function attachSessionPersister(
service: SessionService,
bus: EventBus,
options: SessionPersisterOptions = {},
): () => void {
const storage = options.storage ?? globalThis.localStorage;
const writeIndex = () => {
const file: SessionsFile = {
version: 1,
sessions: service.list(),
activeSessionId: service.getActive(),
};
try {
storage.setItem(SESSIONS_INDEX_KEY, JSON.stringify(file));
if (file.activeSessionId) {
storage.setItem(ACTIVE_SESSION_KEY, file.activeSessionId);
} else {
storage.removeItem(ACTIVE_SESSION_KEY);
}
} catch (err) {
console.warn("attachSessionPersister: write failed", err);
}
};
const writeIndexAndCleanup = () => {
writeIndex();
};
const writeOnDelete = (sessionId: SessionId) => {
writeIndex();
try {
storage.removeItem(engineSnapshotKey(sessionId));
// Also drop the per-session active-document-id key — otherwise
// it gets orphaned and accumulates in localStorage forever.
storage.removeItem(`citation-evidence:session:${sessionId}:active-document-id:v1`);
storage.removeItem(`citation-evidence:session:${sessionId}:capture-state:v1`);
} catch (err) {
console.warn("attachSessionPersister: snapshot cleanup failed", err);
}
};
const offs = [
bus.on("SessionCreated", writeIndexAndCleanup),
bus.on("SessionRenamed", writeIndexAndCleanup),
bus.on("SessionActivated", writeIndexAndCleanup),
bus.on("SessionDeleted", (e) => writeOnDelete(e.sessionId)),
];
return () => {
for (const off of offs) off();
};
}
export interface RestoreSessionsResult {
readonly restored: boolean;
readonly sessions: readonly Session[];
readonly activeSessionId: SessionId | null;
}
/**
* Hydrate the session repo from storage *without* firing events. Mirrors
* `restoreSnapshot`'s "direct repo write" pattern so the persister
* (which is attached after restore) doesn't immediately re-write what
* we just read.
*/
/**
* Wipe every `citation-evidence:*` key from storage. Intended for the
* "Reset all data" UX affordance — gives the user a clean slate without
* having to dig into devtools. Returns the number of keys removed.
*
* The implementation enumerates the storage's keys (via `Storage.length`
* + `Storage.key(i)`) because there is no namespaced `clear()` API.
*/
export function clearAllSessionData(
storage: Pick<Storage, "getItem" | "setItem" | "removeItem" | "key" | "length"> = globalThis.localStorage,
): number {
const toRemove: string[] = [];
for (let i = 0; i < storage.length; i++) {
const k = storage.key(i);
if (k && k.startsWith("citation-evidence:")) toRemove.push(k);
}
for (const k of toRemove) storage.removeItem(k);
return toRemove.length;
}
export function restoreSessionsFromStorage(
repo: SessionRepository,
service: SessionService,
options: SessionPersisterOptions = {},
): RestoreSessionsResult {
const storage = options.storage ?? globalThis.localStorage;
const raw = storage.getItem(SESSIONS_INDEX_KEY);
if (!raw) return { restored: false, sessions: [], activeSessionId: null };
try {
const parsed = JSON.parse(raw) as Partial<SessionsFile>;
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
return { restored: false, sessions: [], activeSessionId: null };
}
for (const s of parsed.sessions) repo.create(s);
const activeRaw =
typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null;
const fallbackActiveRaw = storage.getItem(ACTIVE_SESSION_KEY);
const candidate = (activeRaw ?? fallbackActiveRaw) as SessionId | null;
let activeSessionId: SessionId | null = null;
if (candidate && repo.get(candidate)) {
activeSessionId = candidate;
// Use service.setActive to keep the in-memory activeId aligned —
// suppress the resulting event so we don't bounce the persister.
// The bus listener attached *after* restore is what does the
// persistence, so emitting here is harmless either way; but
// skipping it keeps restore strictly side-effect-free from the
// listener's point of view.
service.setActive(activeSessionId);
}
return { restored: true, sessions: repo.list(), activeSessionId };
} catch (err) {
console.warn("restoreSessionsFromStorage: parse failed", err);
return { restored: false, sessions: [], activeSessionId: null };
}
}

View File

@@ -1,98 +0,0 @@
/**
* Per-session engine snapshot round-trip.
*
* The workplan (CE-WP-0005-T01) requires that two sessions persisted
* under the per-session key scheme can each be restored independently
* — proving the storage layout actually partitions data by session.
*/
import { describe, expect, it } from "vitest";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
import {
attachPersister,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type Engine,
type EngineSnapshot,
} from "./index";
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function seedDoc(engine: Engine, label: string): { id: DocumentId } {
const id = `doc_${label}` as DocumentId;
const repId = `rep_${label}` as RepresentationId;
const document: Document = {
id,
mediaType: "application/pdf",
title: `Doc ${label}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
const representation: DocumentRepresentation = {
id: repId,
documentId: id,
representationType: "pdf-text",
contentHash: `hash-${label}`,
canonicalText: `text for ${label}`,
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 12, pageLength: 12 }],
generatedAt: "2026-05-25T00:00:00.000Z",
};
engine.documents.register({ document, representation });
return { id };
}
describe("per-session engine snapshot round-trip", () => {
it("keeps two sessions' snapshots isolated under per-session storage keys", () => {
const storage = memoryStorage();
const sessA = "sess_aaa" as SessionId;
const sessB = "sess_bbb" as SessionId;
// Author session A.
const engineA = createEngine();
const offA = attachPersister(engineA, { key: engineSnapshotKey(sessA), storage });
const a1 = seedDoc(engineA, "a1");
const a2 = seedDoc(engineA, "a2");
offA();
// Author session B with completely different documents.
const engineB = createEngine();
const offB = attachPersister(engineB, { key: engineSnapshotKey(sessB), storage });
const b1 = seedDoc(engineB, "b1");
offB();
// Restore each into its own fresh engine; assert isolation.
const restoredA = createEngine();
const resA = restoreFromStorage(restoredA, { key: engineSnapshotKey(sessA), storage });
expect(resA.restored).toBe(true);
const aIds = restoredA.documents.list().map((d) => d.id).sort();
expect(aIds).toEqual([a1.id, a2.id].sort());
const restoredB = createEngine();
const resB = restoreFromStorage(restoredB, { key: engineSnapshotKey(sessB), storage });
expect(resB.restored).toBe(true);
const bIds = restoredB.documents.list().map((d) => d.id);
expect(bIds).toEqual([b1.id]);
// Sanity: each snapshot key really does hold a distinct snapshot.
const rawA = storage.getItem(engineSnapshotKey(sessA));
const rawB = storage.getItem(engineSnapshotKey(sessB));
expect(rawA).not.toBeNull();
expect(rawB).not.toBeNull();
const snapA = JSON.parse(rawA!) as EngineSnapshot;
const snapB = JSON.parse(rawB!) as EngineSnapshot;
expect(snapA.documents).toHaveLength(2);
expect(snapB.documents).toHaveLength(1);
});
});

View File

@@ -1,8 +0,0 @@
# `src/shared/` — vocabulary, types, pure helpers
Future home: `citation-engine` (the shared types and contracts half of it).
Owns: `Document`, `Selector`, `Annotation`, `EvidenceItem`, `EvidenceLink`,
`EvidenceSet`, state enums, branded IDs, canonical text normalization.
May import from: nothing internal. Leaf node of the dependency graph
(`wiki/DependencyMap.md` §4).

View File

@@ -1,45 +0,0 @@
/**
* The Annotation type.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.1 (resolutionStatus)
* and `wiki/ArchitectureOverview.md` §4.4. Annotations are the *technical*
* mark on a document range — meaning and commentary live on EvidenceItem.
*/
import type { AnnotationId, DocumentId, RepresentationId } from "./ids";
import type { Selector } from "./selector";
/** Closed enum per `wiki/SharedContracts.md` §2.1. */
export type AnnotationResolutionStatus =
| "resolved"
| "ambiguous"
| "unresolved"
| "stale";
export interface Annotation {
readonly id: AnnotationId;
readonly documentId: DocumentId;
readonly representationId?: RepresentationId;
/**
* All available selectors for this passage, in order of expected
* resolution confidence. Per the §3 redundancy rule, the system stores
* every selector kind it could derive at capture time.
*/
readonly selectors: readonly Selector[];
/** Verbatim canonical text at capture time. */
readonly quote?: string;
/** Short human note attached to the technical mark. */
readonly note?: string;
/**
* Version of `normalize()` that was active when these selectors were
* stored. Recorded so future normalization changes can be detected as a
* migration concern. See `src/shared/text/normalize.ts`.
*/
readonly normalizeVersion: number;
readonly resolutionStatus?: AnnotationResolutionStatus;
readonly createdBy?: string;
/** ISO-8601 timestamp. */
readonly createdAt: string;
/** ISO-8601 timestamp. */
readonly updatedAt: string;
}

View File

@@ -1,75 +0,0 @@
/**
* Unit tests for `resolveSourceLabel()`. Locks the precedence order
* described in workplan CE-WP-0004-T02:
* title → metadata.filename → uri → id.
*/
import { describe, expect, it } from "vitest";
import { resolveSourceLabel } from "./citation-card-source";
import type { Document } from "./document";
import type { DocumentId } from "./ids";
function makeDoc(partial: Partial<Document>): Document {
return {
id: "doc_test" as DocumentId,
mediaType: "application/pdf",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
...partial,
};
}
describe("resolveSourceLabel()", () => {
it("prefers document.title when set", () => {
expect(
resolveSourceLabel(
makeDoc({
title: "Order from 2024",
uri: "https://example.test/o.pdf",
metadata: { filename: "fallback.pdf" },
}),
),
).toBe("Order from 2024");
});
it("falls back to metadata.filename when title is missing", () => {
expect(
resolveSourceLabel(
makeDoc({
uri: "https://example.test/o.pdf",
metadata: { filename: "fallback.pdf" },
}),
),
).toBe("fallback.pdf");
});
it("falls back to uri when title and filename are missing", () => {
expect(
resolveSourceLabel(makeDoc({ uri: "https://example.test/o.pdf" })),
).toBe("https://example.test/o.pdf");
});
it("falls back to id when everything else is missing", () => {
expect(resolveSourceLabel(makeDoc({}))).toBe("doc_test");
});
it("treats whitespace-only strings as empty", () => {
expect(
resolveSourceLabel(
makeDoc({ title: " ", uri: "https://example.test/o.pdf" }),
),
).toBe("https://example.test/o.pdf");
});
it("ignores non-string metadata.filename", () => {
expect(
resolveSourceLabel(
makeDoc({
metadata: { filename: 42 },
uri: "https://example.test/o.pdf",
}),
),
).toBe("https://example.test/o.pdf");
});
});

View File

@@ -1,26 +0,0 @@
/**
* Source-label resolution for citation cards.
*
* Per workplan CE-WP-0004-T02: the label rendered after the quote is
* document.title → document.metadata.filename → document.uri → document.id
* — the first non-empty value wins. Renderers must call this helper so
* the two output formats (Markdown, HTML) stay consistent.
*/
import type { Document } from "./document";
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveSourceLabel(document: Document): string {
const title = nonEmptyString(document.title);
if (title) return title;
const filename = nonEmptyString(document.metadata?.["filename"]);
if (filename) return filename;
const uri = nonEmptyString(document.uri);
if (uri) return uri;
return document.id;
}

View File

@@ -1,35 +0,0 @@
/**
* CitationCard — a presentable rendering of an EvidenceItem.
*
* Implements `wiki/ArchitectureOverview.md` §4.7. Stored or transient,
* always derived from an `EvidenceItem` + its `Document` + the first
* referenced `Annotation`. The renderers in `engine/rendering/` produce
* the string forms (`format: "markdown" | "html"`); the
* `"web-component"` format is reserved for a later workplan (see §14.2).
*
* `id` and `evidenceItemId` use the branded ID types from `./ids` so they
* cannot be swapped at compile time, even though `ArchitectureOverview.md`
* §4.7 shows them as bare `string`s. The brand is invisible at runtime.
*/
import type { CitationCardId, EvidenceItemId } from "./ids";
export type CitationCardFormat = "html" | "markdown" | "web-component";
export interface CitationCard {
readonly id: CitationCardId;
readonly evidenceItemId: EvidenceItemId;
/** Verbatim quote rendered into the card body. */
readonly quote: string;
/**
* Human-readable source label. Resolution order is
* `document.title` → `filename from metadata` → `document.uri` →
* the document id. Renderers should call `resolveSourceLabel()` from
* `./citation-card-source.ts` rather than computing this inline.
*/
readonly sourceLabel: string;
readonly commentary?: string;
/** Deep link to reopen the source context — see `./open-context-url.ts`. */
readonly openContextUrl?: string;
readonly format: CitationCardFormat;
}

View File

@@ -1,91 +0,0 @@
/**
* Document and DocumentRepresentation types.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
* `wiki/ArchitectureOverview.md` §4.1, §4.2. Pure data — no behavior.
*/
import type { DocumentId, RepresentationId } from "./ids";
/**
* The kind of normalized view derived from a source document.
*
* MVP recognises only `pdf-text`; the other variants are reserved for the
* HTML/Markdown/OCR adapters that arrive after CE-WP-0002.
*/
export type RepresentationType =
| "pdf-text"
| "html-dom"
| "markdown-rendered"
| "plain-text"
| "ocr-text";
/**
* Page-level geometry. One entry per physical PDF page.
* Coordinates are PDF user-space points (1/72 inch).
*/
export interface PageInfo {
/** 1-indexed physical page number. */
readonly page: number;
/** Page width in user-space points. */
readonly width: number;
/** Page height in user-space points. */
readonly height: number;
}
export type PageMap = readonly PageInfo[];
/**
* Maps canonical-text offset ranges to physical pages.
*
* Entries are sorted by `globalStart`, are non-overlapping, and together
* cover `[0, canonicalText.length)`. `pageLength` equals
* `globalEnd - globalStart` and is also the length of the page-local text
* (used by `PdfPageTextSelector`).
*/
export interface PageOffsetRange {
readonly page: number;
/** Inclusive canonical-text offset where this page begins. */
readonly globalStart: number;
/** Exclusive canonical-text offset where this page ends. */
readonly globalEnd: number;
/** Length of the page's text in canonical-text characters. */
readonly pageLength: number;
}
export type OffsetMap = readonly PageOffsetRange[];
/**
* Reserved for `StructuralSelector` (heading/section/AST path).
* Not implementable in MVP — type is `never` to enforce that at compile time.
*/
export type StructureMap = never;
/** A source document known to the system. */
export interface Document {
readonly id: DocumentId;
readonly title?: string;
readonly uri?: string;
readonly mediaType: string;
readonly fingerprint?: string;
readonly version?: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly metadata?: Readonly<Record<string, unknown>>;
}
/** A normalized, addressable view of a `Document`. */
export interface DocumentRepresentation {
readonly id: RepresentationId;
readonly documentId: DocumentId;
readonly representationType: RepresentationType;
/** Hash of the canonical text — stable identifier for the representation. */
readonly contentHash: string;
/** Canonical text after `normalize()` is applied. */
readonly canonicalText?: string;
readonly pageMap?: PageMap;
readonly structureMap?: StructureMap;
readonly offsetMap?: OffsetMap;
/** ISO-8601 timestamp. */
readonly generatedAt: string;
}

View File

@@ -1,62 +0,0 @@
/**
* Conformance test: the runtime enum lists in `evidence-link.ts` must
* match the lists in `wiki/SharedContracts.md` §2.4 and §2.5 exactly.
*
* If you intentionally change an enum, update both the doc and the
* runtime list together — this test will tell you which one you forgot.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
EVIDENCE_LINK_STATUS_VALUES,
EVIDENCE_RELATION_VALUES,
} from "./evidence-link";
const HERE = fileURLToPath(new URL(".", import.meta.url));
const CONTRACTS_PATH = resolve(HERE, "../../wiki/SharedContracts.md");
function extractFencedListAfterHeading(markdown: string, heading: string): string[] {
const headingIndex = markdown.indexOf(heading);
if (headingIndex === -1) {
throw new Error(`Could not find heading "${heading}" in SharedContracts.md`);
}
const after = markdown.slice(headingIndex + heading.length);
const fenceOpen = after.indexOf("```");
if (fenceOpen === -1) throw new Error(`No fenced block after "${heading}"`);
const bodyStart = after.indexOf("\n", fenceOpen) + 1;
const fenceClose = after.indexOf("```", bodyStart);
if (fenceClose === -1) throw new Error(`Unterminated fenced block after "${heading}"`);
const body = after.slice(bodyStart, fenceClose);
return body
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Strip trailing " — explanatory note" if present (none in §2.4/§2.5 today,
// but §2.1/§2.2 use that style — being defensive keeps the helper reusable).
.map((line) => line.split(/\s+[—-]\s+/)[0]!.trim());
}
describe("EvidenceLink enum conformance with SharedContracts.md", () => {
const markdown = readFileSync(CONTRACTS_PATH, "utf8");
it("§2.4 EvidenceLink.status matches EVIDENCE_LINK_STATUS_VALUES", () => {
const docValues = extractFencedListAfterHeading(
markdown,
"### 2.4 `EvidenceLink.status` (per target)",
);
expect(docValues).toEqual([...EVIDENCE_LINK_STATUS_VALUES]);
});
it("§2.5 EvidenceLink.relation matches EVIDENCE_RELATION_VALUES", () => {
const docValues = extractFencedListAfterHeading(
markdown,
"### 2.5 `EvidenceLink.relation`",
);
expect(docValues).toEqual([...EVIDENCE_RELATION_VALUES]);
});
});

View File

@@ -1,107 +0,0 @@
/**
* EvidenceLink + EvidenceTarget shapes.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.4
* (EvidenceLink.status) and §2.5 (EvidenceLink.relation), and
* `wiki/ArchitectureOverview.md` §4 (target-type catalogue).
*
* An EvidenceLink ties exactly one EvidenceItem to one structured target
* (e.g. a form field). Multiple links per item are allowed when the same
* evidence supports several targets. Multiple links per target are allowed
* when several pieces of evidence apply to the same field — the
* EvidenceSet captures that ordered group.
*/
import type { EvidenceItemId, EvidenceLinkId } from "./ids";
/**
* Closed enum per `wiki/SharedContracts.md` §2.4.
*
* `no-evidence` is a *derived* state — computed when a target has zero
* links — and is therefore NOT stored on a link itself. The stored values
* are the five members of `EvidenceLinkStoredStatus`.
*/
export type EvidenceLinkStatus =
| "no-evidence"
| "candidate"
| "confirmed"
| "conflicting"
| "insufficient"
| "verified";
/**
* The subset of `EvidenceLinkStatus` that may appear on a stored link
* record. `no-evidence` is excluded because it is derived from the
* absence of links on a target, not stored.
*/
export type EvidenceLinkStoredStatus = Exclude<EvidenceLinkStatus, "no-evidence">;
/** Closed enum per `wiki/SharedContracts.md` §2.5. */
export type EvidenceRelation =
| "supports"
| "contradicts"
| "explains"
| "qualifies"
| "source-for"
| "context-for";
/**
* Known target-type catalogue per `wiki/ArchitectureOverview.md` §4
* (`EvidenceTargetType`). The MVP only exercises `"form-field"`; the
* others are reserved so future workplans can extend without renaming.
*/
export type EvidenceTargetType =
| "form-field"
| "claim"
| "requirement"
| "decision"
| "document-section";
/**
* Generic shape of an evidence target. `targetId` is opaque to the engine
* — the host subsystem (form renderer, claims index, …) owns the
* namespace for its `targetType`.
*/
export interface EvidenceTarget {
readonly targetType: EvidenceTargetType;
readonly targetId: string;
}
export interface EvidenceLink {
readonly id: EvidenceLinkId;
readonly evidenceItemId: EvidenceItemId;
readonly targetType: EvidenceTargetType;
readonly targetId: string;
readonly relation: EvidenceRelation;
readonly status: EvidenceLinkStoredStatus;
/** Optional 0..1 confidence assigned by user or auto-process. */
readonly confidence?: number;
readonly createdBy?: string;
/** ISO-8601 timestamp. */
readonly createdAt: string;
/** ISO-8601 timestamp. */
readonly updatedAt: string;
}
/**
* The canonical lists, exported for use by enum-conformance tests
* (see `evidence-link.test.ts`) and for any UI code that needs to
* enumerate options. Order matches `wiki/SharedContracts.md`.
*/
export const EVIDENCE_LINK_STATUS_VALUES: readonly EvidenceLinkStatus[] = [
"no-evidence",
"candidate",
"confirmed",
"conflicting",
"insufficient",
"verified",
];
export const EVIDENCE_RELATION_VALUES: readonly EvidenceRelation[] = [
"supports",
"contradicts",
"explains",
"qualifies",
"source-for",
"context-for",
];

View File

@@ -1,36 +0,0 @@
/**
* EvidenceSet — an ordered group of evidence items pointed at a target.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
* `wiki/ArchitectureOverview.md` §4.6.
*
* The set itself is target-shaped: it carries the `(targetType, targetId)`
* pair so the binder can answer "give me the EvidenceSet for this form
* field" in one call. `activeEvidenceItemId` is the membership of the
* set that the UI is currently focused on; cycling Tab/Shift-Tab through
* the field's chips updates it.
*/
import type { EvidenceItemId, EvidenceSetId } from "./ids";
import type { EvidenceTargetType } from "./evidence-link";
export interface EvidenceSet {
readonly id: EvidenceSetId;
readonly label?: string;
/**
* Optional target binding. Form-field sets always carry these; ad-hoc
* topical sets may leave them undefined.
*/
readonly targetType?: EvidenceTargetType;
readonly targetId?: string;
/**
* Membership in display order. The binder is free to reorder, but
* persistence preserves this order so cycling is deterministic.
*/
readonly evidenceItemIds: readonly EvidenceItemId[];
/**
* The currently active member, or undefined if the set is empty or
* no member is yet focused.
*/
readonly activeEvidenceItemId?: EvidenceItemId;
}

View File

@@ -1,37 +0,0 @@
/**
* EvidenceItem type.
*
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.2 (status enum)
* and `wiki/ArchitectureOverview.md` §4.5. An EvidenceItem is the *meaning*
* layer on top of one or more technical Annotations.
*/
import type { AnnotationId, EvidenceItemId } from "./ids";
/** Closed enum per `wiki/SharedContracts.md` §2.2. */
export type EvidenceItemStatus =
| "candidate"
| "confirmed"
| "rejected"
| "needs-check";
export interface EvidenceItem {
readonly id: EvidenceItemId;
/**
* One or more annotations that together constitute the evidence.
* Multiple annotations are used when a piece of evidence spans
* discontiguous passages.
*/
readonly annotationIds: readonly AnnotationId[];
readonly title?: string;
readonly commentary?: string;
readonly status: EvidenceItemStatus;
/** Optional 0..1 confidence assigned by user or auto-process. */
readonly confidence?: number;
readonly tags?: readonly string[];
readonly createdBy?: string;
/** ISO-8601 timestamp. */
readonly createdAt: string;
/** ISO-8601 timestamp. */
readonly updatedAt: string;
}

View File

@@ -1,21 +0,0 @@
import { describe, it, expect } from "vitest";
import { newId } from "./ids";
describe("newId", () => {
it("returns ids with the expected prefix for each kind", () => {
expect(newId("document")).toMatch(/^doc_[0-9a-f-]{36}$/);
expect(newId("representation")).toMatch(/^rep_[0-9a-f-]{36}$/);
expect(newId("annotation")).toMatch(/^ann_[0-9a-f-]{36}$/);
expect(newId("evidence")).toMatch(/^ev_[0-9a-f-]{36}$/);
expect(newId("evidence-set")).toMatch(/^evset_[0-9a-f-]{36}$/);
expect(newId("evidence-link")).toMatch(/^evlink_[0-9a-f-]{36}$/);
expect(newId("citation-card")).toMatch(/^card_[0-9a-f-]{36}$/);
expect(newId("citation-recovery")).toMatch(/^crec_[0-9a-f-]{36}$/);
});
it("returns a unique id on every call", () => {
const a = newId("annotation");
const b = newId("annotation");
expect(a).not.toBe(b);
});
});

View File

@@ -1,58 +0,0 @@
/**
* Branded ID types and the `newId(kind)` factory.
*
* Implements the identifier portion of `wiki/SharedContracts.md` §1 and
* `wiki/ArchitectureOverview.md` §3.2. Each branded type is structurally a
* `string` but nominally distinct, so passing an `AnnotationId` where a
* `DocumentId` is required is a compile-time error.
*/
declare const __brand: unique symbol;
type Brand<K, T extends string> = K & { readonly [__brand]: T };
export type DocumentId = Brand<string, "DocumentId">;
export type RepresentationId = Brand<string, "RepresentationId">;
export type AnnotationId = Brand<string, "AnnotationId">;
export type EvidenceItemId = Brand<string, "EvidenceItemId">;
export type EvidenceSetId = Brand<string, "EvidenceSetId">;
export type EvidenceLinkId = Brand<string, "EvidenceLinkId">;
export type CitationCardId = Brand<string, "CitationCardId">;
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
export type SessionId = Brand<string, "SessionId">;
export type IdKindMap = {
document: DocumentId;
representation: RepresentationId;
annotation: AnnotationId;
evidence: EvidenceItemId;
"evidence-set": EvidenceSetId;
"evidence-link": EvidenceLinkId;
"citation-card": CitationCardId;
"citation-recovery": CitationRecoveryAttemptId;
session: SessionId;
};
export type IdKind = keyof IdKindMap;
const PREFIXES: Record<IdKind, string> = {
document: "doc",
representation: "rep",
annotation: "ann",
evidence: "ev",
"evidence-set": "evset",
"evidence-link": "evlink",
"citation-card": "card",
"citation-recovery": "crec",
session: "sess",
};
/**
* Mint a new branded identifier of the requested kind.
*
* IDs use the shape `<prefix>_<uuid>` so they are human-recognizable when
* they show up in logs, URLs, or stored JSON.
*/
export function newId<K extends IdKind>(kind: K): IdKindMap[K] {
return `${PREFIXES[kind]}_${crypto.randomUUID()}` as IdKindMap[K];
}

View File

@@ -1,13 +0,0 @@
export * from "./ids";
export * from "./document";
export * from "./selector";
export * from "./annotation";
export * from "./evidence";
export * from "./evidence-link";
export * from "./evidence-set";
export * from "./citation-card";
export * from "./citation-card-source";
export * from "./open-context-url";
export * from "./session";
export * from "./session-archive";
export { normalize, NORMALIZE_VERSION } from "./text/normalize";

View File

@@ -1,32 +0,0 @@
/**
* Unit tests for `openContextUrl()`. Locks the shape from
* `wiki/ArchitectureOverview.md` §14.3.
*/
import { describe, expect, it } from "vitest";
import type { AnnotationId, DocumentId } from "./ids";
import { openContextUrl } from "./open-context-url";
const DOC = "doc_abc-123" as DocumentId;
const ANN = "ann_def-456" as AnnotationId;
describe("openContextUrl()", () => {
it("produces the canonical /viewer?document=…&annotation=… shape", () => {
expect(openContextUrl({ documentId: DOC, annotationId: ANN })).toBe(
"/viewer?document=doc_abc-123&annotation=ann_def-456",
);
});
it("percent-encodes reserved characters in ids", () => {
const ugly = "doc with spaces&=#" as DocumentId;
const url = openContextUrl({ documentId: ugly, annotationId: ANN });
expect(url).toBe(
"/viewer?document=doc%20with%20spaces%26%3D%23&annotation=ann_def-456",
);
// round-trip
const params = new URL(url, "https://example.test").searchParams;
expect(params.get("document")).toBe("doc with spaces&=#");
expect(params.get("annotation")).toBe("ann_def-456");
});
});

View File

@@ -1,36 +0,0 @@
/**
* Open-context URL — the canonical deep link that lets a citation card
* reopen the source context that backs it.
*
* Implements `wiki/ArchitectureOverview.md` §14.3. The URL shape is:
*
* /viewer?document=<documentId>&annotation=<annotationId>
*
* Both ids are mandatory. The annotation alone is insufficient because
* the router needs the document id to mount the right viewer adapter
* before resolving the annotation's selectors.
*
* This convention is stable across persistence modes — when the
* in-memory engine is replaced by a real backend (ADR-0005), the same
* URL shape is expected to still resolve.
*/
import type { AnnotationId, DocumentId } from "./ids";
export interface OpenContextUrlInput {
readonly documentId: DocumentId;
readonly annotationId: AnnotationId;
}
/**
* Build the deep link for a given (document, annotation) pair.
*
* Query-string values are percent-encoded via `encodeURIComponent` so that
* any future id scheme containing reserved characters (`&`, `=`, `#`) still
* round-trips.
*/
export function openContextUrl(input: OpenContextUrlInput): string {
const docPart = encodeURIComponent(input.documentId);
const annPart = encodeURIComponent(input.annotationId);
return `/viewer?document=${docPart}&annotation=${annPart}`;
}

View File

@@ -1,79 +0,0 @@
/**
* The Selector discriminated union.
*
* Implements `wiki/SharedContracts.md` §3. Each selector kind has a unique
* `type` discriminator and locates a passage inside one
* `DocumentRepresentation`.
*
* The MVP implements the four PDF-relevant variants
* (`TextQuoteSelector`, `TextPositionSelector`, `PdfRectSelector`,
* `PdfPageTextSelector`). The other three kinds (DOM, structural, fragment)
* are reserved as `never`-typed stubs so adding them later is a localised
* change.
*/
/** Exact quote with optional surrounding context (W3C-aligned). */
export interface TextQuoteSelector {
readonly type: "TextQuoteSelector";
/** The verbatim quoted passage from the canonical text. */
readonly exact: string;
/** Up to ~32 chars of canonical text immediately before `exact`. */
readonly prefix?: string;
/** Up to ~32 chars of canonical text immediately after `exact`. */
readonly suffix?: string;
}
/** Canonical-text character offsets (inclusive start, exclusive end). */
export interface TextPositionSelector {
readonly type: "TextPositionSelector";
readonly start: number;
readonly end: number;
}
/** A rectangle on a PDF page, in page-relative normalized coordinates (0..1). */
export interface NormalizedRect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/** One or more rectangles on a single PDF page. */
export interface PdfRectSelector {
readonly type: "PdfRectSelector";
/** 1-indexed physical page number. */
readonly page: number;
readonly rects: readonly NormalizedRect[];
}
/** Page-local text offsets, for a single PDF page. */
export interface PdfPageTextSelector {
readonly type: "PdfPageTextSelector";
readonly page: number;
readonly start: number;
readonly end: number;
}
/** Reserved for HTML/Markdown viewer adapters. Not implementable in MVP. */
export type DomRangeSelector = never;
/** Reserved for heading/section/AST-path locators. Not implementable in MVP. */
export type StructuralSelector = never;
/** Reserved for exported deep-link fragments. Not implementable in MVP. */
export type FragmentSelector = never;
/**
* The closed union of all selector kinds. The `never` members keep the union
* exhaustive so future selector additions are a single edit.
*/
export type Selector =
| TextQuoteSelector
| TextPositionSelector
| PdfRectSelector
| PdfPageTextSelector
| DomRangeSelector
| StructuralSelector
| FragmentSelector;
export type SelectorType = Selector["type"];

View File

@@ -1,88 +0,0 @@
import { describe, expect, it } from "vitest";
import type { DocumentId, SessionId } from "./ids";
import {
parseSessionArchiveManifest,
SESSION_ARCHIVE_SCHEMA_VERSION,
SessionArchiveParseError,
type SessionArchiveManifest,
} from "./session-archive";
function validManifest(): SessionArchiveManifest {
return {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt: "2026-05-25T00:00:00.000Z",
session: {
id: "sess_abc" as SessionId,
name: "Demo",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
engine: {
version: 1,
documents: [],
representations: [],
annotations: [],
evidenceItems: [],
},
documentBindings: [
{
documentId: "doc_abc" as DocumentId,
filename: "demo.pdf",
fingerprint: "abc123",
},
],
};
}
describe("parseSessionArchiveManifest", () => {
it("round-trips a valid manifest through JSON.stringify + parse", () => {
const m = validManifest();
const round = parseSessionArchiveManifest(JSON.parse(JSON.stringify(m)));
expect(round).toEqual(m);
});
it("rejects an unsupported schemaVersion", () => {
const m = { ...validManifest(), schemaVersion: 999 as unknown };
expect(() => parseSessionArchiveManifest(m)).toThrow(SessionArchiveParseError);
expect(() => parseSessionArchiveManifest(m)).toThrow(/unsupported schemaVersion/);
});
it("rejects a missing required top-level field", () => {
const m = validManifest();
const broken = { ...m, exportedAt: undefined as unknown };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/exportedAt/);
});
it("rejects a malformed session sub-object", () => {
const m = validManifest();
const broken = { ...m, session: { ...m.session, id: 12345 as unknown } };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/session\.id/);
});
it("rejects a malformed engine snapshot", () => {
const m = validManifest();
const broken = { ...m, engine: { ...m.engine, version: "1" as unknown } };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/engine\.version/);
});
it("rejects a non-array documentBindings", () => {
const m = validManifest();
const broken = { ...m, documentBindings: "nope" as unknown };
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings/);
});
it("rejects a malformed documentBindings entry", () => {
const m = validManifest();
const broken = {
...m,
documentBindings: [{ documentId: "doc_x", fingerprint: "abc" }] as unknown[],
};
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings\[0\]\.filename/);
});
it("rejects a non-object root", () => {
expect(() => parseSessionArchiveManifest("oops")).toThrow(/manifest/);
expect(() => parseSessionArchiveManifest(null)).toThrow(/manifest/);
});
});

View File

@@ -1,150 +0,0 @@
/**
* SessionArchiveManifest — JSON contract for `manifest.json` inside a
* session ZIP archive.
*
* Schema version 1; see `docs/decisions/ADR-0008-session-archive-format.md`
* for the authoritative spec. This module is the executable contract:
* `parseSessionArchiveManifest` validates an `unknown` JSON value and
* narrows it to `SessionArchiveManifest` or throws with a useful
* message.
*
* The `engine` field re-uses the in-memory `EngineSnapshot` shape so
* the in-memory ↔ archive round-trip stays a one-way conversion.
* Only minimal structural validation runs here; the engine helpers
* (`restoreSnapshot`) handle deeper validation when actually
* hydrating an engine.
*/
import type { DocumentId, SessionId } from "./ids";
export const SESSION_ARCHIVE_SCHEMA_VERSION = 1 as const;
export interface SessionArchiveSessionRecord {
readonly id: SessionId;
readonly name: string;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface SessionArchiveDocumentBinding {
readonly documentId: DocumentId;
readonly filename: string;
readonly fingerprint: string;
}
/**
* Mirror of `EngineSnapshot` — kept loose here (record of unknown) to
* avoid the cross-package dependency back into `@engine`. The engine
* persistence layer owns the authoritative shape.
*/
export interface SessionArchiveEngineSnapshot {
readonly version: number;
readonly documents: readonly unknown[];
readonly representations: readonly unknown[];
readonly annotations: readonly unknown[];
readonly evidenceItems: readonly unknown[];
}
export interface SessionArchiveManifest {
readonly schemaVersion: typeof SESSION_ARCHIVE_SCHEMA_VERSION;
readonly exportedAt: string;
readonly session: SessionArchiveSessionRecord;
readonly engine: SessionArchiveEngineSnapshot;
readonly documentBindings: readonly SessionArchiveDocumentBinding[];
}
export class SessionArchiveParseError extends Error {
constructor(message: string) {
super(`SessionArchiveManifest parse failed: ${message}`);
this.name = "SessionArchiveParseError";
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertString(value: unknown, field: string): string {
if (typeof value !== "string") {
throw new SessionArchiveParseError(`field "${field}" must be a string`);
}
return value;
}
function assertObject(value: unknown, field: string): Record<string, unknown> {
if (!isObject(value)) {
throw new SessionArchiveParseError(`field "${field}" must be an object`);
}
return value;
}
function assertArray(value: unknown, field: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new SessionArchiveParseError(`field "${field}" must be an array`);
}
return value;
}
function parseSessionRecord(raw: unknown): SessionArchiveSessionRecord {
const obj = assertObject(raw, "session");
return {
id: assertString(obj.id, "session.id") as SessionId,
name: assertString(obj.name, "session.name"),
createdAt: assertString(obj.createdAt, "session.createdAt"),
updatedAt: assertString(obj.updatedAt, "session.updatedAt"),
};
}
function parseDocumentBinding(
raw: unknown,
index: number,
): SessionArchiveDocumentBinding {
const obj = assertObject(raw, `documentBindings[${index}]`);
return {
documentId: assertString(obj.documentId, `documentBindings[${index}].documentId`) as DocumentId,
filename: assertString(obj.filename, `documentBindings[${index}].filename`),
fingerprint: assertString(obj.fingerprint, `documentBindings[${index}].fingerprint`),
};
}
function parseEngineSnapshot(raw: unknown): SessionArchiveEngineSnapshot {
const obj = assertObject(raw, "engine");
const version = obj.version;
if (typeof version !== "number") {
throw new SessionArchiveParseError(`field "engine.version" must be a number`);
}
const documents = assertArray(obj.documents, "engine.documents");
const representations = assertArray(obj.representations, "engine.representations");
const annotations = assertArray(obj.annotations, "engine.annotations");
const evidenceItems = assertArray(obj.evidenceItems, "engine.evidenceItems");
return {
version,
documents,
representations,
annotations,
evidenceItems,
};
}
export function parseSessionArchiveManifest(raw: unknown): SessionArchiveManifest {
const obj = assertObject(raw, "manifest");
const schemaVersion = obj.schemaVersion;
if (schemaVersion !== SESSION_ARCHIVE_SCHEMA_VERSION) {
throw new SessionArchiveParseError(
`unsupported schemaVersion ${String(schemaVersion)} — expected ${SESSION_ARCHIVE_SCHEMA_VERSION}`,
);
}
const exportedAt = assertString(obj.exportedAt, "exportedAt");
const session = parseSessionRecord(obj.session);
const engine = parseEngineSnapshot(obj.engine);
const documentBindings = assertArray(obj.documentBindings, "documentBindings").map(
(entry, i) => parseDocumentBinding(entry, i),
);
return {
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
exportedAt,
session,
engine,
documentBindings,
};
}

View File

@@ -1,26 +0,0 @@
/**
* `Session` — a user-named workspace that owns one engine snapshot.
*
* Sessions partition the demo app: each one holds its own documents,
* annotations, evidence items, and links. Membership is implicit — a
* session "owns" whatever lives in its engine snapshot. The session
* record itself only carries the human metadata (name, timestamps) and
* the branded id used as a key in `localStorage` and the ZIP archive
* (see ADR-0008).
*
* The id is opaque (`sess_<uuid>` per `ids.ts`). The name is the human
* label; uniqueness is enforced by the `SessionService` on create and
* rename. Names are *trimmed* before storage so a leading/trailing
* space does not let two sessions coexist with effectively the same
* label.
*/
import type { SessionId } from "./ids";
export interface Session {
readonly id: SessionId;
readonly name: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly lastOpenedAt?: string;
}

View File

@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import { NORMALIZE_VERSION, normalize } from "./normalize.js";
describe("normalize (NORMALIZE_VERSION=1)", () => {
it("returns the version constant alongside the text", () => {
const out = normalize("hello");
expect(out.version).toBe(NORMALIZE_VERSION);
expect(out.text).toBe("hello");
});
it("applies Unicode NFC composition", () => {
// "é" decomposed (e + combining acute) vs precomposed.
const decomposed = "café";
const precomposed = "café";
expect(normalize(decomposed).text).toBe(precomposed);
});
it("normalizes CRLF and CR line endings to LF", () => {
expect(normalize("a\r\nb\rc").text).toBe("a\nb\nc");
});
it("collapses horizontal whitespace runs to a single space", () => {
expect(normalize("a b\t\tc d").text).toBe("a b c d");
});
it("preserves paragraph boundaries but collapses 3+ blank lines to one", () => {
const input = "para one\n\n\n\npara two\n\npara three";
expect(normalize(input).text).toBe("para one\n\npara two\n\npara three");
});
it("strips soft hyphens (German line-broken word reassembly)", () => {
// German "Donau­dampf­schiff" line-broken with soft hyphens.
expect(normalize("Donau­dampf­schiff").text).toBe(
"Donaudampfschiff",
);
});
it("strips soft hyphens that span a newline ('word-\\nfragment' → 'wordfragment')", () => {
expect(normalize("word­\nfragment").text).toBe("wordfragment");
});
it("does not mangle ligatures (preserves the round-trip)", () => {
// The ligature "fi" (U+FB01) is left as-is — NFC does NOT decompose it.
// Test documents that current behavior so a future change is intentional.
expect(normalize("efficient").text).toBe("efficient");
});
it("handles a mixed-whitespace paragraph realistically", () => {
const input = " First line \r\n Second line.\r\n\r\n\r\nNext para. ";
expect(normalize(input).text).toBe("First line\nSecond line.\n\nNext para.");
});
it("returns an empty string for whitespace-only input", () => {
expect(normalize(" \n\n \t ").text).toBe("");
});
});

View File

@@ -1,49 +0,0 @@
// Canonical text normalization for selectors and stored quotes.
// Contract: wiki/SharedContracts.md §6.
//
// IMPORTANT: NORMALIZE_VERSION is stored on every Annotation. Bumping it is a
// migration event — old selectors must be re-resolved against re-normalized
// text before the new version becomes the default.
export const NORMALIZE_VERSION = 1;
// Soft hyphen (U+00AD), optionally followed by a single \n so that a PDF-
// extracted "word­\nfragment" reassembles to "wordfragment" rather than
// leaving a stray line break in the middle of a hyphenated word.
const SOFT_HYPHEN_AT_BREAK = /­\n?/g;
// Horizontal whitespace = any \s except \n and \r. The double-negation
// [^\S\r\n] is the idiomatic regex: \S is "not whitespace", so
// "not (not-whitespace or line-ending)" = "whitespace that is not a newline".
// Covers space, tab, NBSP, narrow NBSP, em-space, all Zs general-category.
const HORIZONTAL_WHITESPACE_RUN = /[^\S\r\n]+/g;
// 3+ newlines collapse to exactly two (one paragraph boundary).
const PARAGRAPH_RUN = /\n{3,}/g;
export function normalize(input: string): { text: string; version: number } {
// 1. Unicode NFC.
let text = input.normalize("NFC");
// 2. Normalize line endings: CRLF and CR -> LF.
text = text.replace(/\r\n?/g, "\n");
// 4. Strip soft hyphens (U+00AD) — including the line break that follows
// one — so PDF line-broken hyphenations reassemble. Done before
// horizontal collapse so no stray space remains.
text = text.replace(SOFT_HYPHEN_AT_BREAK, "");
// 3. Collapse horizontal whitespace runs to a single space.
text = text.replace(HORIZONTAL_WHITESPACE_RUN, " ");
// 5. Preserve paragraph boundaries (\n\n); collapse 3+ blank lines to 2.
text = text.replace(PARAGRAPH_RUN, "\n\n");
// Trim line-edge whitespace left over after horizontal collapse.
text = text.replace(/ +\n/g, "\n").replace(/\n +/g, "\n");
// Trim leading/trailing whitespace from the whole document.
text = text.trim();
return { text, version: NORMALIZE_VERSION };
}

View File

@@ -8,22 +8,13 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
import { captureStateKey, loadCaptureState } from "@app/forms/capture-persistence";
interface ViewerProps {
pdfUrl: string;
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
}
vi.mock("@anchor/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (_props: ViewerProps) => (
const MockPdfSpikeViewer = () => (
<div data-testid="mock-pdf-viewer" />
);
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };

View File

@@ -8,20 +8,11 @@ import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Selector } from "@shared/selector";
import type { PdfSelectionCapture } from "@anchor/index";
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
interface ViewerProps {
pdfUrl: string;
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
}
vi.mock("@anchor/index", async (importOriginal) => {
const original = await importOriginal<typeof import("@anchor/index")>();
const MockPdfSpikeViewer = (_props: ViewerProps) => (
const MockPdfSpikeViewer = () => (
<div data-testid="mock-pdf-viewer" />
);
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };

View File

@@ -25,8 +25,8 @@
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"],
"@engine/*": ["src/engine/*"],
"@shared/*": ["../citation-engine/src/shared/*"],
"@engine/*": ["../citation-engine/src/engine/*"],
"@anchor/*": ["src/anchor/*"],
"@source/*": ["src/source/*"],
"@work/*": ["src/work/*"],

View File

@@ -31,8 +31,8 @@ export default defineConfig({
],
resolve: {
alias: {
"@shared": resolve(__dirname, "src/shared"),
"@engine": resolve(__dirname, "src/engine"),
"@shared": resolve(__dirname, "../citation-engine/src/shared"),
"@engine": resolve(__dirname, "../citation-engine/src/engine"),
"@anchor": resolve(__dirname, "src/anchor"),
"@source": resolve(__dirname, "src/source"),
"@binder": resolve(__dirname, "src/binder"),
@@ -43,7 +43,7 @@ export default defineConfig({
server: {
fs: {
// Allow Vite to serve /fixtures/pdfs/*.pdf from the project root.
allow: [resolve(__dirname)],
allow: [resolve(__dirname), resolve(__dirname, "../citation-engine")],
},
},
optimizeDeps: {

View File

@@ -7,7 +7,7 @@ repo: citation-evidence
repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
topic_slug: citation_evidence_mvp
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
status: active
status: done
owner: codex
created: "2026-06-22"
updated: "2026-06-22"
@@ -51,7 +51,7 @@ T01 (add package dependency + resolve aliases)
```task
id: CE-WP-0009-T01
status: wait
status: done
priority: critical
depends_on: []
```
@@ -77,7 +77,7 @@ next to umbrella, same layout as today).
```task
id: CE-WP-0009-T02
status: wait
status: done
priority: critical
depends_on: [T01]
```
@@ -103,7 +103,7 @@ Do **not** change import statements in `anchor/`, `source/`, `binder/`,
```task
id: CE-WP-0009-T03
status: wait
status: done
priority: critical
depends_on: [T02]
```
@@ -125,7 +125,7 @@ Update partition README pointers in remaining `src/*/` folders to reference
```task
id: CE-WP-0009-T04
status: wait
status: done
priority: critical
depends_on: [T03]
```