Compare commits

...

8 Commits

Author SHA1 Message Date
3e2f35ca5e Draft capability entry (reuse-surface REUSE-WP-0017-T04, cohort 1)
Honest first-pass maturity vector grounded in README/docs/tests present
in this repo; no invented evidence. Flagged for human review before
publish. See reuse-surface history/2026-07-06-coverage-classification.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 19:03:42 +02:00
ed64f0c57d Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:24 +02:00
c2c86c7c7c chore(state-hub): write back CENG-WP workstream and task IDs
Sync CENG-WP-0001/0002 frontmatter with state-hub after fix-consistency.
2026-06-22 20:28:43 +02:00
75e85a1e36 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for citation-engine
2026-06-22 20:27:36 +02:00
697bc90780 Implement CENG-WP-0002: package distribution and publish readiness
Resolve ADR-0002 in umbrella, add install docs, contract sync runbook,
publishing checklist, and package files whitelist.
2026-06-22 19:45:11 +02:00
a9e3b76a38 Add CENG-WP-0002 package distribution workplan
Document ADR-0002 resolution, publish readiness, and SharedContracts
sync. Update CENG-WP-0001 follow-up to reference CE-WP-0009 and CENG-WP-0002.
2026-06-22 19:32:59 +02:00
78085e1eb3 Extract engine from citation-evidence umbrella (CENG-WP-0001)
Bootstrap @citation-evidence/engine as a standalone TypeScript package
with shared types and engine services copied from the umbrella MVP.
All 89 tests pass with lint and typecheck clean.
2026-06-22 18:02:05 +02:00
62ba1d1140 Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:35 +02:00
74 changed files with 8978 additions and 216 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-engine` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/CENG-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone",
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **citation-engine** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Core domain model, API contracts, persistence interfaces, citation rendering. Leaf node — depends on no other subsystem. INTENT-only during umbrella-first MVP.
**Domain:** infotech
**Repo slug:** citation-engine
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("infotech")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="citation-engine", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-engine&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:citation-engine]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=citation-engine
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=citation-engine
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/CENG-WP-NNNN-<slug>.md`
ID prefix: `CENG-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-CENG-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:citation-engine]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: CENG-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -1,22 +1,18 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually --> <!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — citation-engine # Custodian Brief — citation-engine
**Domain:** citation_evidence **Domain:** infotech
**Last synced:** 2026-06-21 23:28 UTC **Last synced:** 2026-06-22 18:27 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams ## Active Workstreams
### INTENT placeholder — await extraction from citation-evidence *(none — repo may need first-session setup)*
Progress: 0/1 done | workstream_id: `ccd217a2-572d-435d-9a29-e0a55b6a4311`
**Open tasks:**
- ! Extraction gate `755e36b7`
--- ---
## MCP Orientation (when available) ## MCP Orientation (when available)
If the state-hub MCP server is reachable, call: If the state-hub MCP server is reachable, call:
`get_domain_summary("citation_evidence")` `get_domain_summary("infotech")`
This provides richer cross-domain context. This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source. If the MCP call fails, use this file as your orientation source.

175
.gitignore vendored
View File

@@ -1,176 +1,7 @@
# ---> Python node_modules/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/ dist/
downloads/ coverage/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log *.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env .env
.venv .venv
env/ tsconfig.tsbuildinfo
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.10.0

19
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,19 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: agent
category: project
domain: infotech
secondary_domains: []
capability_tags:
- citations
- evidence
- knowledge
business_stake:
- technology
- product
- operations
business_mechanics:
- coordination
- operation

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# citation-engine — Agent Instructions
## Repo Identity
**Purpose:** Core domain model, API contracts, persistence interfaces, citation rendering. Leaf node — depends on no other subsystem. INTENT-only during umbrella-first MVP.
**Domain:** infotech
**Repo slug:** citation-engine
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `CENG-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-engine&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=citation-engine&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=citation-engine
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-engine` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/CITATION-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-CITATION-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: CITATION-WP-NNNN
type: workplan
title: "..."
domain: infotech
repo: citation-engine
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: CITATION-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=citation-engine`
(or send a message to the hub agent via `POST /messages/`)

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# citation-engine — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

View File

@@ -318,15 +318,14 @@ This repository should be:
--- ---
## MVP Coordination — Code Lives Upstream ## MVP Coordination — Extraction Complete
During the umbrella-first MVP phase (decided 2026-05-24), **the source code The umbrella-first MVP (CE-WP-0001..0008) stabilized the engine API. Code now
for this subsystem does not live in this repository yet**. It lives in the lives in this repository under `src/shared/` and `src/engine/`, extracted from
umbrella repo at `citation-evidence/src/engine/` and `citation-evidence/src/shared/`. `citation-evidence` per `workplans/CENG-WP-0001-intent-placeholder.md`.
This INTENT.md documents the *intended* responsibilities and boundaries. The umbrella consumes this package via `link:../citation-engine` per `CE-WP-0009`.
When the engine's interfaces have stabilized through actual MVP use, the This repository is the canonical home going forward.
corresponding code extracts into this repository.
**Shared contracts** (vocabulary, state enums, relation types, selector **Shared contracts** (vocabulary, state enums, relation types, selector
taxonomy, event types, viewer adapter, canonical text normalization, allowed taxonomy, event types, viewer adapter, canonical text normalization, allowed

121
README.md
View File

@@ -1,19 +1,112 @@
# citation-engine # citation-engine
Domain model and tooling for citations — the shared vocabulary Core domain model and engine services for the citation-evidence ecosystem.
(`Document`, `Selector`, `Annotation`, `EvidenceItem`, `EvidenceLink`,
`EvidenceSet`, state enums, branded IDs, canonical text normalization)
together with the services and repositories that act on it.
## MVP status: INTENT only This package provides the shared vocabulary (`Document`, `Selector`, `Annotation`,
`EvidenceItem`, `EvidenceLink`, `EvidenceSet`, state enums, branded IDs,
canonical text normalization) together with in-memory repositories, orchestration
services, the event bus, and citation card renderers.
During the citation-evidence MVP, code lives upstream in Published as **`@citation-evidence/engine`**.
[`citation-evidence`](../citation-evidence/) under `src/shared/` and
`src/engine/`. This repo currently holds `INTENT.md` describing what will
move here once the engine API stabilises. Until extraction, contract
changes belong in
[`citation-evidence/wiki/SharedContracts.md`](../citation-evidence/wiki/SharedContracts.md),
not here.
The dependency map (`citation-evidence/wiki/DependencyMap.md`) marks this ## Layout
package as a **leaf node**: nothing internal depends on anything else.
```
src/
shared/ # types, enums, pure helpers
engine/ # services, repositories, rendering, persistence
wiki/
SharedContracts.md # conformance copy (authoritative: citation-evidence)
```
## Installing
### Local development (sibling checkout)
Repos are expected as siblings:
```
~/citation-evidence/
~/citation-engine/
~/evidence-anchor/
...
```
In the consumer's `package.json`:
```json
{
"dependencies": {
"@citation-evidence/engine": "link:../citation-engine"
}
}
```
Then `pnpm install`. TypeScript path aliases in consumers typically map
`@shared/*` and `@engine/*` to `../citation-engine/src/shared/*` and
`../citation-engine/src/engine/*` (see `CE-WP-0009` in citation-evidence).
### Registry install (when published)
```bash
pnpm add @citation-evidence/engine
```
Registry host TBD. See [Publishing checklist](#publishing-checklist) below.
### evidence-anchor (placeholder)
When `evidence-anchor` extracts, it will depend on engine types only:
```json
{
"dependencies": {
"@citation-evidence/engine": "link:../citation-engine"
}
}
```
Import shared types via `@shared/*` path aliases or `@citation-evidence/engine/shared`.
## Development
```bash
pnpm install
pnpm test
pnpm typecheck
pnpm lint
```
## Contract sync
See [`wiki/README.md`](wiki/README.md). Changes to shared contracts land in
`citation-evidence/wiki/SharedContracts.md` first, then sync here.
## Extraction status
Code lives in `src/shared/` and `src/engine/`, extracted from the umbrella
MVP per `workplans/CENG-WP-0001-intent-placeholder.md`. The umbrella consumes
this package via `link:` per `CE-WP-0009`.
## Publishing checklist
Use when a package registry is configured (ADR-0002: independent repos + publish).
- [ ] Set `private: false` (or scoped private per org policy)
- [ ] Confirm `files` in `package.json` includes `src/`, `wiki/`, `README.md`, `LICENSE`
- [ ] Decide: TypeScript sources via `exports` (current) vs `tsc` emit to `dist/`
- [ ] Bump semver: patch for fixes, minor for additive contracts, major for breaks
- [ ] Run `pnpm test && pnpm typecheck && pnpm lint` before publish
- [ ] Update consumers from `link:` to registry version
- [ ] Document registry URL and auth in org runbook (not in this repo)
No publish CI step is configured yet — credentials and registry choice are
out of scope until an external consumer requires it.
## Related repos
- [`citation-evidence`](../citation-evidence/) — umbrella reference workspace
- [`evidence-anchor`](../evidence-anchor/) — selector creation and resolution
- [`evidence-source`](../evidence-source/) — document ingestion
- [`citation-work`](../citation-work/) — review workspace UX
- [`evidence-binder`](../evidence-binder/) — evidence-to-field linking

137
SCOPE.md Normal file
View File

@@ -0,0 +1,137 @@
# SCOPE
> This file helps you quickly understand what this repository is about,
> when it is relevant, and when it is not.
> It is intentionally lightweight and may be incomplete.
---
## One-liner
<!-- Describe the purpose of this repository in one precise sentence. -->
<!-- Example: "Provides a lightweight event router for Kubernetes-native systems." -->
---
## Core Idea
<!-- What is the main capability or idea behind this repository? -->
<!-- What problem does it try to solve? -->
---
## In Scope
<!-- What this repository is responsible for. -->
<!-- Be explicit and concrete. -->
-
-
-
---
## Out of Scope
<!-- What this repository deliberately does NOT do. -->
<!-- This is often more important than "In Scope". -->
-
-
-
---
## Relevant When
<!-- When should someone consider using or exploring this repository? -->
-
-
-
---
## Not Relevant When
<!-- When should someone ignore this repository? -->
-
-
-
---
## Current State
<!-- Rough indication of maturity. No strict format required. -->
- Status: <!-- e.g. concept / experimental / active / stable / deprecated -->
- Implementation: <!-- e.g. idea / partial / substantial / complete -->
- Stability: <!-- e.g. unstable / evolving / stable -->
- Usage: <!-- e.g. none / personal / internal / production -->
<!-- Add any notes that help set expectations. -->
---
## How It Fits
<!-- Where does this repository sit in the bigger picture? -->
- Upstream dependencies:
- Downstream consumers:
- Often used with:
---
## Terminology
<!-- Terms that are important to understand this repo. -->
<!-- Especially useful if naming differs from other repos. -->
- Preferred terms:
- Also known as:
- Potentially confusing terms:
---
## Related / Overlapping Repositories
<!-- List repositories that have similar or adjacent responsibilities. -->
<!-- Helps detect duplication and navigate the ecosystem. -->
- <repo-name> — <!-- how it relates -->
---
## Getting Oriented
<!-- If someone decides to look deeper, where should they start? -->
- Start with:
- Key files / directories:
- Entry points:
---
## Provided Capabilities
<!-- What can this repo's domain provide to other domains on request? -->
<!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
<!-- Remove the examples and add your own, or leave empty if none. -->
<!--
```capability
type: infrastructure
title: Example capability title
description: What this capability provides, in one or two sentences.
keywords: [keyword1, keyword2, keyword3]
```
-->
---
## Notes
<!-- Anything else worth knowing. Keep it short. -->

48
eslint.config.js Normal file
View File

@@ -0,0 +1,48 @@
// ESLint flat config — enforces shared/engine dependency boundary.
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";
export default tseslint.config(
{
ignores: ["dist/", "node_modules/", "coverage/", "**/*.d.ts"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: { ...globals.node },
},
plugins: {
boundaries,
import: importPlugin,
},
settings: {
"import/resolver": {
typescript: { project: "./tsconfig.json" },
},
"boundaries/elements": [
{ type: "shared", pattern: "src/shared/**" },
{ type: "engine", pattern: "src/engine/**" },
],
},
rules: {
"boundaries/element-types": [
2,
{
default: "disallow",
rules: [
{ from: "shared", allow: [] },
{ from: "engine", allow: ["shared"] },
],
},
],
},
},
);

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@citation-evidence/engine",
"version": "0.1.0",
"private": true,
"description": "Core domain model and engine services for the citation-evidence ecosystem.",
"license": "Apache-2.0",
"type": "module",
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=20.10.0"
},
"files": [
"src",
"wiki",
"README.md",
"LICENSE"
],
"exports": {
".": "./src/engine/index.ts",
"./shared": "./src/shared/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit"
},
"devDependencies": {
"@types/node": "^20.14.0",
"eslint": "^9.7.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-boundaries": "^4.2.2",
"eslint-plugin-import": "^2.30.0",
"globals": "^15.9.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.0",
"vitest": "^2.0.5"
}
}

3476
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
---
id: capability.infotech.citation-engine
name: Citation Evidence Domain Engine
summary: Core domain model and engine services for the citation-evidence ecosystem — shared vocabulary,
in-memory repositories, orchestration services, event bus, and citation card renderers.
owner: citation-engine
status: draft
domain: infotech
tags:
- citation
- evidence
- domain-model
maturity:
discovery:
current: D3
target: D5
confidence: medium
rationale: README documents the domain vocabulary (Document, Selector, Annotation, EvidenceItem/Link/Set)
and points to wiki/SharedContracts.md as the authoritative conformance copy shared with citation-evidence.
availability:
current: A1
target: A3
confidence: medium
rationale: Published as `@citation-evidence/engine`, consumed today only as a local sibling-checkout
package link; no registry publish yet.
external_evidence:
completeness:
level: C1
confidence: low
basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations:
- shared vocabulary and branded IDs
- in-memory repositories and orchestration services
- citation card renderers
broken_expectations: []
out_of_scope_expectations: []
reliability:
level: R0
confidence: low
basis: consumer_quality_signals
known_reliability_risks:
- only consumed by sibling citation-evidence today; no independent external consumer
discovery:
intent: Provide the shared domain model and engine services underlying the citation-evidence workspace,
decoupled from any single UI shell.
includes:
- Document/Selector/Annotation/EvidenceItem/EvidenceLink/EvidenceSet types
- in-memory repositories, event bus, orchestration services
- citation card rendering
excludes:
- the evidence-workspace UI shell (owned by citation-evidence)
assumptions: []
use_cases: []
research_memos: []
availability:
current_level: A1
target_level: A3
current_artifacts:
- '`@citation-evidence/engine` npm package (local link, not published)'
target_artifacts: []
consumption_modes:
- library import (workspace link)
relations:
depends_on: []
supports: []
related_to: []
evidence:
documentation:
- README.md
- wiki/SharedContracts.md
tests:
- vitest.config.ts
consumer_feedback: []
bug_reports: []
incidents: []
consumer_guidance:
recommended_for:
- citation-evidence and future citation-domain consumers within the same monorepo/workspace
not_recommended_for:
- consumers outside the citation-evidence workspace (package not published to a registry)
known_limitations:
- not yet published; only consumable via sibling-checkout workspace link
promotion_history: []
---
# Citation Evidence Domain Engine
## Overview
`citation-engine` is the extracted domain model and engine layer for the citation-evidence ecosystem, published (workspace-local) as `@citation-evidence/engine` and consumed by the citation-evidence umbrella app.
## Assessment notes
### Discovery
README documents the domain vocabulary (Document, Selector, Annotation, EvidenceItem/Link/Set) and points to wiki/SharedContracts.md as the authoritative conformance copy shared with citation-evidence.
### Availability
Published as `@citation-evidence/engine`, consumed today only as a local sibling-checkout package link; no registry publish yet.
### Completeness
First-pass honest assessment from the REUSE-WP-0017 coverage campaign
(reuse-surface). No external consumer feedback exists yet; levels reflect
scope-vs-intent documentation quality, not internal code quality.
### Reliability
No production consumer telemetry exists yet; reliability level is
intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence.
## Promotion checklist
- [x] ID follows `capability.<domain>.<name>` pattern
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
- [x] `external_evidence` is populated separately from `maturity`
- [ ] Relations reference valid capability IDs (none yet)
- [x] Index entry added in `registry/indexes/capabilities.yaml`

View File

@@ -1,4 +1,19 @@
version: 1 version: 1
updated: '2026-06-16' updated: '2026-07-06'
domain: helix_forge domain: helix_forge
capabilities: [] capabilities:
- id: capability.infotech.citation-engine
name: Citation Evidence Domain Engine
summary: Core domain model and engine services for the citation-evidence ecosystem — shared vocabulary,
in-memory repositories, orchestration services, event bus, and citation card renderers.
vector: D3 / A1 / C1 / R0
domain: infotech
status: draft
owner: citation-engine
path: registry/capabilities/capability.infotech.citation-engine.md
tags:
- citation
- evidence
- domain-model
consumption_modes:
- library import (workspace link)

7
src/engine/README.md Normal file
View File

@@ -0,0 +1,7 @@
# `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).

168
src/engine/engine.test.ts Normal file
View File

@@ -0,0 +1,168 @@
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

@@ -0,0 +1,64 @@
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);
});
});

79
src/engine/events/bus.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* 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

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

159
src/engine/events/types.ts Normal file
View File

@@ -0,0 +1,159 @@
/**
* 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 }>;

62
src/engine/index.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* 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

@@ -0,0 +1,209 @@
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();
});
});

146
src/engine/persistence.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* 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: _blobUri, ...rest } = document;
void _blobUri;
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

@@ -0,0 +1,172 @@
/**
* 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

@@ -0,0 +1,92 @@
/**
* 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

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

View File

@@ -0,0 +1,194 @@
/**
* 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

@@ -0,0 +1,68 @@
/**
* 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

@@ -0,0 +1,47 @@
/**
* 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

@@ -0,0 +1,166 @@
/**
* 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;
},
},
};
}

12
src/engine/repos/index.ts Normal file
View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,129 @@
/**
* 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

@@ -0,0 +1,74 @@
/**
* 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

@@ -0,0 +1,142 @@
/**
* 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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,250 @@
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

@@ -0,0 +1,362 @@
/**
* 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

@@ -0,0 +1,98 @@
/**
* 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);
});
});

8
src/shared/README.md Normal file
View File

@@ -0,0 +1,8 @@
# `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).

45
src/shared/annotation.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* 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

@@ -0,0 +1,75 @@
/**
* 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

@@ -0,0 +1,26 @@
/**
* 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

@@ -0,0 +1,35 @@
/**
* 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;
}

91
src/shared/document.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* 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

@@ -0,0 +1,62 @@
/**
* 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]);
});
});

107
src/shared/evidence-link.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* 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

@@ -0,0 +1,36 @@
/**
* 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;
}

37
src/shared/evidence.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* 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;
}

21
src/shared/ids.test.ts Normal file
View File

@@ -0,0 +1,21 @@
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);
});
});

58
src/shared/ids.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* 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];
}

13
src/shared/index.ts Normal file
View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,32 @@
/**
* 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

@@ -0,0 +1,36 @@
/**
* 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}`;
}

79
src/shared/selector.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* 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

@@ -0,0 +1,88 @@
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

@@ -0,0 +1,150 @@
/**
* 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,
};
}

26
src/shared/session.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* `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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,49 @@
// 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 };
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"],
"@engine/*": ["src/engine/*"]
}
},
"include": ["src", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@shared": resolve(__dirname, "src/shared"),
"@engine": resolve(__dirname, "src/engine"),
},
},
test: {
environment: "node",
globals: false,
},
});

22
wiki/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Wiki — citation-engine
## SharedContracts sync
`SharedContracts.md` in this directory is a **conformance copy** for tests
(e.g. `evidence-link.test.ts` enum parity checks). It is not authoritative.
**Authoritative source:** `citation-evidence/wiki/SharedContracts.md`
### When contracts change
1. Land the change in `citation-evidence/wiki/SharedContracts.md` (and any
runtime types in `citation-engine/src/shared/` if enums or shapes change).
2. Copy/sync to `citation-engine/wiki/SharedContracts.md`.
3. Run `pnpm test` in **both** repos.
4. Bump `@citation-evidence/engine` version if the change is breaking for
consumers.
### Test reference path
`src/shared/evidence-link.test.ts` reads `wiki/SharedContracts.md` relative
to `src/shared/` (`../../wiki/SharedContracts.md`).

306
wiki/SharedContracts.md Normal file
View File

@@ -0,0 +1,306 @@
# Shared Contracts — citation-evidence
This document is the **single source of truth** for everything that more than one
subsystem in the citation-evidence ecosystem must agree on:
- the **vocabulary** (entity names and what they mean),
- the **canonical state enums** for entities that flow across repo boundaries,
- the **relation type** vocabulary,
- the **selector type** taxonomy,
- the **event type** vocabulary,
- the **ownership rules** for shared types versus shared behavior.
The five sister repos (`citation-engine`, `evidence-anchor`, `evidence-source`,
`citation-work`, `evidence-binder`) defer to this document. When their
`INTENT.md` files refer to "shared contracts", they mean this file.
During the umbrella-first MVP phase, the **TypeScript implementations** of
these contracts live in `citation-evidence/src/shared/` and are imported by
the per-subsystem code under `citation-evidence/src/{engine,anchor,source,work,binder}/`.
When a subsystem extracts to its own repo, it takes its slice of the shared
types with it — but this document remains the canonical vocabulary.
---
## 1. Vocabulary
These nine entities are the vocabulary every subsystem uses.
| Entity | One-line definition | Owner (post-extraction) |
|---------------------------|----------------------------------------------------------------------------------------------------|-------------------------|
| `Document` | An identified source object: PDF, Markdown, HTML, scan, etc. | `citation-engine` |
| `DocumentRepresentation` | A normalized, addressable view of a document (canonical text, page map, structure). | `citation-engine` |
| `Selector` | A technical locator for a passage inside a representation. | `citation-engine` (types) / `evidence-anchor` (behavior) |
| `Annotation` | A technical mark on a document range, expressed as one or more selectors plus quote text. | `citation-engine` |
| `EvidenceItem` | A meaningful evidence object built from one or more annotations, with commentary and status. | `citation-engine` |
| `EvidenceSet` | An ordered group of evidence items associated with a target or topic. | `citation-engine` (type) / `evidence-binder` (behavior) |
| `EvidenceLink` | A relation between an `EvidenceItem` and a structured target (form field, claim, requirement, …). | `citation-engine` (type) / `evidence-binder` (behavior) |
| `CitationCard` | A renderable, exportable presentation of an evidence item. | `citation-engine` |
| `CitationRecoveryAttempt` | A traceable attempt to locate a cited passage from an external clue. | `citation-engine` (type) / `evidence-source` (behavior) |
**Ownership rule:** *types and interfaces flow downward from `citation-engine`;
behavior flows upward into the specialised repos*. Where the table shows a
split, the engine repo holds the data shape and the other repo holds the
algorithms and lifecycle.
---
## 2. Canonical state enums
These enums are the authoritative values. Subsystems must not invent local
variants without updating this document first.
### 2.1 `Annotation.resolutionStatus`
```
resolved — selectors located the passage with high confidence
ambiguous — multiple plausible candidates found
unresolved — no plausible candidate found
stale — representation has changed since selectors were stored
```
### 2.2 `EvidenceItem.status`
```
candidate — captured but not yet vetted
confirmed — verified by a user as useful evidence
rejected — explicitly discarded
needs-check — flagged for review
```
> **Note:** earlier subsystem drafts introduced `strong-support`, `weak-support`,
> and `contradicts` on the item. Those concepts now live on the **link**, not
> the item — see §2.4.
### 2.3 `Document.reviewStatus` (when used by `citation-work`)
```
unreviewed
in-review
relevant
rejected
needs-follow-up
cited
verified
```
`citation-work` may treat any of these as the active state; the canonical
storage lives on the Document record in `citation-engine`.
### 2.4 `EvidenceLink.status` (per target)
```
no-evidence
candidate
confirmed
conflicting
insufficient
verified
```
`no-evidence` is a *derived* state computed when a target has zero links;
it is not stored on a link itself.
### 2.5 `EvidenceLink.relation`
```
supports
contradicts
explains
qualifies
source-for
context-for
```
This is the closed vocabulary for the MVP. Adding a relation requires updating
this document and the `EvidenceLink` schema together.
### 2.6 `CitationRecoveryAttempt.state`
```
created
source-found-fulltext
source-found-preview-only
source-found-metadata-only
source-not-found
quote-found
quote-not-found
candidate-passages-found
manual-confirmation-needed
confirmed
annotation-created
failed
```
---
## 3. Selector taxonomy
A `Selector` is a discriminated union of:
```
TextQuoteSelector exact quote + prefix/suffix context
TextPositionSelector canonical text start/end offsets
PdfRectSelector page number + normalized page rectangles
PdfPageTextSelector page number + page-local text offsets
DomRangeSelector DOM path + range offsets (HTML/Markdown)
StructuralSelector heading/section/AST path
FragmentSelector exported fragment / deep link (export-only)
```
**Selector redundancy rule:** when an annotation is created, the system stores
*all selector types that are available* for that document representation, not
just one. Resolution tries them in order of expected confidence and stops at
the first high-confidence match.
W3C Web Annotation mapping uses these same concepts but as JSON-LD; the mapping
is documented separately (see ADR-0003 — pending).
---
## 4. Event vocabulary
Events are the primary integration mechanism between subsystems. The closed
event vocabulary for the MVP is:
```
DocumentImported
DocumentRepresentationGenerated
DocumentRemoved
AnnotationCreated
AnnotationResolved
AnnotationResolutionFailed
EvidenceItemCreated
EvidenceItemUpdated
EvidenceLinkCreated
EvidenceLinkUpdated
EvidenceItemActivated
FormFieldActivated
CitationCardRendered
CitationRecoveryStarted
CitationRecoveryCandidateFound
CitationRecoveryConfirmed
SessionCreated
SessionRenamed
SessionDeleted
SessionActivated
```
The `Session*` events live on the cross-session session bus (the
SessionService's own EventBus instance — see CE-WP-0005). The remaining
events live on the per-session engine bus and are scoped to whatever
session is currently active.
Subsystems must emit these events through a shared event bus owned by
`citation-engine`. Subsystems may listen to any event but must not invent
event types without updating this document.
---
## 5. Viewer adapter contract
Viewer adapters are the bridge between a document format and the rest of the
system. They are **owned by `evidence-anchor`** as far as the contract goes;
concrete adapters may live in either `evidence-anchor` or `evidence-source`
depending on whether the heavy lifting is selector logic or document
representation logic.
```ts
interface DocumentViewerAdapter {
mediaTypes: string[];
load(document: Document, representation?: DocumentRepresentation): Promise<void>;
getCurrentSelection(): Promise<SelectionCapture | null>;
createSelectorsFromSelection(selection: SelectionCapture): Promise<Selector[]>;
resolveSelectors(selectors: Selector[]): Promise<AnchorResolution>;
scrollToResolvedTarget(target: ResolvedAnchorTarget, opts?: { center?: boolean; behavior?: "auto"|"smooth" }): Promise<void>;
renderHighlight(target: ResolvedAnchorTarget, opts?: HighlightRenderOptions): Promise<void>;
getHighlightClientRects(annotationId: string): Promise<DOMRect[]>;
}
```
MVP delivers a single `PDFViewerAdapter`. HTML and Markdown adapters are
deferred.
---
## 6. Canonical text normalization
All text-based selectors and quote matching depend on a deterministic
normalization function. The MVP normalization is:
1. Unicode NFC normalization.
2. Replace all line-ending sequences with `\n`.
3. Collapse runs of horizontal whitespace into a single space.
4. Strip soft hyphens (U+00AD).
5. Preserve paragraph boundaries (double `\n`).
**This function is versioned.** Stored selectors record the normalization
version they were created against. Changing the function later requires either
backwards-compatible behavior or a re-anchoring migration.
The reference implementation lives in `citation-evidence/src/shared/text/normalize.ts`.
---
## 7. Visual guide rect registry
The visual-guide overlay (form field → evidence card → source highlight)
requires DOM rects from three independently-rendered subsystems. The contract
is a **rect registry** owned by `evidence-binder`:
```ts
interface RectRegistry {
register(kind: "field" | "evidence-card" | "highlight", id: string, getRect: () => DOMRect | null): () => void;
getRect(kind: "field" | "evidence-card" | "highlight", id: string): DOMRect | null;
subscribe(listener: (event: RectRegistryEvent) => void): () => void;
}
```
Each renderer (form, evidence sidebar, viewer adapter) registers a
`getRect` callback. The overlay queries on-demand and re-renders on scroll,
resize, focus, and active-evidence change.
This contract MUST be defined and stable before any of the three renderers
hardens, or the overlay becomes the system's coupling bottleneck.
---
## 8. Ownership rules (the short version)
1. **Types and interfaces** flow downward from `citation-engine`.
2. **Behavior and algorithms** live in the specialised repos.
3. Where a concept appears in both a type and a behavior context (e.g.
`Selector`, `EvidenceLink`, `EvidenceSet`, `CitationRecoveryAttempt`),
the engine owns the shape and the specialised repo owns the lifecycle.
4. **The shared event bus is engine-owned**; subsystems publish and subscribe
but do not extend the event vocabulary unilaterally.
5. **No new enum values, relation types, event types, or selector kinds**
land in code without first appearing in this document.
6. During umbrella-first MVP: rules 1-5 are aspirational. We will tolerate
small violations in `citation-evidence/src/` and reconcile during extraction.
---
## 9. Change process
Changes to this document are change to the contract.
- Small additions (a new enum value, a new event type) can be made in a single
PR that updates this doc + the type definitions + at least one consumer.
- Breaking changes (renaming an entity, removing a state, changing an
ownership split) require a short ADR in `docs/decisions/` and a heads-up
progress event on the state-hub.
---
## 10. Pending ADRs that will affect this document
These are listed in `docs/decisions/` once written. Until then the document
reflects the current best understanding from the architecture overview.
- **ADR-0001** — Umbrella-first MVP strategy (decided 2026-05-24, this session).
- **ADR-0002** — Monorepo vs polyrepo packaging (pending).
- **ADR-0003** — W3C Web Annotation: lossy mapping vs round-trip guarantee (pending).
- **ADR-0004** — PDF viewer library choice: `react-pdf-highlighter-plus` vs PDF.js direct (pending).
- **ADR-0005** — Persistence: local-first SQLite vs Postgres from day one (pending).
- **ADR-0006** — Selector ownership split (types in engine, algorithms in anchor) (pending — implied here).

View File

@@ -1,31 +1,105 @@
--- ---
id: CENG-WP-0001 id: CENG-WP-0001
type: workplan type: workplan
title: "INTENT placeholder — await extraction from citation-evidence" title: "Extract engine from citation-evidence umbrella"
domain: citation_evidence domain: infotech
repo: citation-engine repo: citation-engine
status: active status: done
owner: codex owner: codex
topic_slug: citation_evidence_mvp topic_slug: citation_evidence_mvp
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
created: "2026-06-21" created: "2026-06-21"
updated: "2026-06-21" updated: "2026-06-22"
state_hub_workstream_id: "ccd217a2-572d-435d-9a29-e0a55b6a4311" state_hub_workstream_id: "ccd217a2-572d-435d-9a29-e0a55b6a4311"
--- ---
# CENG-WP-0001 — INTENT Placeholder # CENG-WP-0001 — Extract Engine from Umbrella
Umbrella-first MVP: engine code lives in `citation-evidence/src/engine/` and Extract `citation-evidence/src/shared/` and `citation-evidence/src/engine/` into
`citation-evidence/src/shared/`. This file satisfies ADR-001 workplan structure this repository as a standalone TypeScript package (`@citation-evidence/engine`).
until extraction per `INTENT.md`.
## Extraction gate Prerequisite: umbrella MVP complete (CE-WP-0001..0008).
## Dependency order
```
T01 (toolchain scaffold)
└─ T02 (copy shared/ + engine/ from umbrella)
└─ T03 (conformance reference: wiki/SharedContracts.md)
└─ T04 (test + typecheck + lint green)
```
---
## T01 — Toolchain scaffold
```task ```task
id: CENG-WP-0001-T01 id: CENG-WP-0001-T01
status: wait status: done
priority: low priority: critical
state_hub_task_id: "755e36b7-983d-48e8-af94-fd074db7f745" state_hub_task_id: "755e36b7-983d-48e8-af94-fd074db7f745"
``` ```
Blocked on citation-evidence MVP stabilization and engine API contract freeze. Deliverables:
See `citation-evidence/wiki/DependencyMap.md`. - `package.json` with `test`, `lint`, `typecheck` scripts
- `tsconfig.json` with `@shared/*` and `@engine/*` path aliases
- `vitest.config.ts`, `eslint.config.js`, `.nvmrc`
- Node-oriented `.gitignore`
---
## T02 — Extract shared/ and engine/
```task
id: CENG-WP-0001-T02
status: done
priority: critical
depends_on: [T01]
state_hub_task_id: "01c0295a-d524-48cc-848c-d55eefbedbe2"
```
Copy the umbrella partitions verbatim:
- `src/shared/` — domain types, enums, normalization
- `src/engine/` — services, repos, events, rendering, persistence
No import path changes required; aliases match the umbrella layout.
---
## T03 — SharedContracts conformance reference
```task
id: CENG-WP-0001-T03
status: done
priority: high
depends_on: [T02]
state_hub_task_id: "aee0fcae-2a26-47a7-be0b-b2940f4c7767"
```
Copy `wiki/SharedContracts.md` from the umbrella so enum conformance tests
(`evidence-link.test.ts`) keep a local reference.
---
## T04 — Verification
```task
id: CENG-WP-0001-T04
status: done
priority: critical
depends_on: [T02, T03]
state_hub_task_id: "46f76120-e5b3-40ef-b5e3-599db1c4b82b"
```
- `pnpm test` — 89 tests pass
- `pnpm typecheck` — clean
- `pnpm lint` — clean (shared/engine boundary enforced)
---
## Follow-up
- `CE-WP-0009` — wire `citation-evidence` to consume this package via `link:` and
delete in-repo copies (`citation-evidence/workplans/CE-WP-0009-engine-workspace-wireup.md`).
- `CENG-WP-0002` — ADR-0002, publish readiness, contract sync
(`workplans/CENG-WP-0002-package-distribution.md`).

View File

@@ -0,0 +1,161 @@
---
id: CENG-WP-0002
type: workplan
title: "Package distribution — ADR-0002, publish readiness, contract sync"
domain: infotech
repo: citation-engine
status: done
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-06-22"
updated: "2026-06-22"
planning_order: 2
planning_priority: high
depends_on_workplan: CENG-WP-0001
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
spec_refs:
- ../citation-evidence/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md
- ../citation-evidence/wiki/SharedContracts.md
- ../citation-evidence/wiki/DependencyMap.md
state_hub_workstream_id: "c1141068-d969-4367-bd6f-d159efb8332d"
---
# CENG-WP-0002 — Package Distribution
Make `@citation-evidence/engine` consumable beyond a sibling `link:` checkout:
resolve the monorepo-vs-polyrepo decision, document the install story for sister
repos, and prepare optional registry publish.
Prerequisite: `CENG-WP-0001` complete. `CE-WP-0009` (umbrella wireup) can
proceed in parallel using `link:`; publish tasks here depend on T01.
## Dependency order
```
T01 (resolve ADR-0002)
└─ T02 (consumer install docs + export surface)
└─ T03 (SharedContracts sync runbook)
└─ T04 (publish readiness — optional until registry chosen)
```
---
## T01 — Resolve ADR-0002
```task
id: CENG-WP-0002-T01
status: done
priority: critical
depends_on: []
state_hub_task_id: "ed44ca36-d834-4fe7-8a02-82e5a921b95d"
```
Fill in `citation-evidence/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md`:
- **Decision** — pick A, B, or C for the citation-evidence ecosystem.
- **Recommended default** (if no strong preference): **B — six independent repos
with published packages**, using `link:../citation-engine` during local dev
until a registry is configured.
- **Consequences** — how sister repos install engine, how versions bump, how
`SharedContracts.md` stays authoritative.
Stop and ask the repo owner if the choice is genuinely ambiguous.
**Acceptance:** ADR status moves from `proposed` to `accepted`; decision and
consequences sections are filled.
---
## T02 — Consumer install docs and export surface
```task
id: CENG-WP-0002-T02
status: done
priority: high
depends_on: [T01]
state_hub_task_id: "5726bfff-e3ff-4942-a5d8-3a69107695e7"
```
Document in `citation-engine/README.md`:
- **Local dev** — `link:../citation-engine` (sibling checkout layout).
- **Post-publish** — `pnpm add @citation-evidence/engine` (registry TBD).
Review and stabilize `package.json` `exports`:
```json
{
".": "./src/engine/index.ts",
"./shared": "./src/shared/index.ts"
}
```
Confirm TypeScript consumers can resolve both entry points without deep imports
into `src/`. Add `types` conditions or subpath exports only if a consumer
repo hits resolution errors during `CE-WP-0009`.
**Acceptance:** README has copy-paste install snippets for umbrella and one
sister repo placeholder (`evidence-anchor`).
---
## T03 — SharedContracts sync runbook
```task
id: CENG-WP-0002-T03
status: done
priority: high
depends_on: [T01]
state_hub_task_id: "deb05231-751a-4b3b-812a-62ad3016724a"
```
`citation-evidence/wiki/SharedContracts.md` remains the **authoritative**
contract. `citation-engine/wiki/SharedContracts.md` is a conformance copy for
enum tests (`evidence-link.test.ts`).
Write a short runbook (in `citation-engine/README.md` or `wiki/README.md`):
1. Contract change lands in umbrella `wiki/SharedContracts.md`.
2. Copy/sync to `citation-engine/wiki/SharedContracts.md`.
3. Update runtime enums/types in `src/shared/` if needed.
4. Run `pnpm test` in both repos.
**Acceptance:** runbook exists; `evidence-link.test.ts` path to local
`wiki/SharedContracts.md` is documented.
---
## T04 — Publish readiness (deferred until registry exists)
```task
id: CENG-WP-0002-T04
status: done
priority: low
depends_on: [T01, T02]
state_hub_task_id: "9087896a-7779-42e0-8844-aaa8de1016e9"
```
Prepare for first registry publish when a host is chosen (npm, GitHub Packages,
private Verdaccio, etc.):
- Decide `private: false` vs scoped private package.
- Add `files` whitelist (`src/`, `wiki/`, `README.md`, `LICENSE`).
- Optional: `tsc` emit to `dist/` if consumers cannot consume TypeScript
sources directly (decide based on ADR-0002 choice and first consumer).
- Versioning policy: semver from `0.1.0`; breaking contract changes bump minor
or major per `SharedContracts.md` § change process.
- CI publish step stub (document only — no registry credentials in repo).
**Acceptance:** a "Publishing checklist" section in README; package is
structurally publishable even if no publish has occurred yet.
---
## Relationship to other workplans
| Workplan | Role |
|----------|------|
| `CENG-WP-0001` | Extraction — **done** |
| `CE-WP-0009` | Umbrella consumes engine via `link:`**done** |
| `CENG-WP-0002` | Distribution strategy and publish prep — this file |
| Future `EANCH-WP-0002`, etc. | Sister-repo extractions depend on engine install story from T02 |