EVA / adr
Architecture Decision Records for the eva-hq platform. Each entry captures the context, the decision made, and its consequences — auditable in docs/adr/.
ADR-001: JSON Schema as canonical source of truth for tasks.v1 contract
context
Five duplicate task-shape definitions existed across eva-hq (tasks-store.mjs, list.mjs, eva_tasks.py, CASA's EvaTask interface, CASA's RawTask interface). Any field addition or rename required touching all five independently, with no mechanical check that they stayed in sync.
decision
Introduce a single contracts/tasks.v1.json (JSON Schema Draft 2020-12) as the canonical wire shape. A build script (bin/eva-contracts-build.mjs) generates derived artifacts:
contracts/tasks.v1.d.ts — TypeScript types for CASA vendoring
contracts/tasks_v1.py — Python TypedDict for eva-hq scripts
contracts/tasks.v1.fields.json — runtime-loadable field list for ESM consumers
A verify script (bin/eva-contracts-verify.mjs) gates CI on schema ↔ SQL drift.
consequences
Positive:
- Single edit point for field changes; build + verify enforce consistency mechanically
- Contract tests (
tests/test_tasks_v1_contract.py) pin invariants and catch regressions
- Downstream consumers (CASA, petrova) can vendor the
.d.ts with a SHA reference
Negative:
- Build step required before consuming generated files; contributors must run
npm run contracts:build after schema changes
- The Python file uses
tasks_v1.py (underscore) not tasks.v1.py (dot) because Python module names cannot contain dots — a minor naming inconsistency with the JSON Schema $id
alternatives considered
- Proto/Thrift schema: heavier toolchain, not warranted for a single-table wire shape
- TypeScript source as canonical: would exclude the Python toolchain from mechanical guarantees
- Manual sync: status quo; rejected because it already caused drift
ADR-002: Fire-and-forget ARES emission for eva.act.v1 / casa.eva.act.v1
context
The tasks.v1 instrumentation goal is to collect seven days of transition events across both eva-hq and CASA to inform the next architectural decision (synchronous gating policy). The emission path must not break existing mutation flows if ARES is unavailable or slow.
decision
Emit to ARES topics (eva.act.v1, casa.eva.act.v1) as fire-and-forget:
- The
emit() call is never awaited by the caller
- Failures are caught internally and logged to stderr; they never propagate to the caller
- A 3-second timeout aborts stalled requests
- A 220 ms minimum inter-call gate prevents bursting
- When
ARES_API_KEY is unset, emit() is a no-op — safe in all non-production environments
Both sides share this contract: eva-hq via bin/lib/eva-act-emit.mjs wrapping the existing bin/lib/ares-emit.mjs; CASA via a TypeScript port at src/lib/eva/ares-emit.ts.
consequences
Positive:
- Zero latency impact on mutation routes; ARES outages are invisible to operators
- Safe in CI and dev (no-op without the API key)
- Symmetric pattern across both repos reduces cognitive overhead
Negative:
- No delivery guarantee; ARES queue may miss events during outages (acceptable for week-1 observability, not for billing or audit)
- Failures are silent in production logs unless someone watches stderr — mitigated by the
console.warn path in ares-emit
alternatives considered
- Synchronous emission: rejected — would add ARES latency (~50–200 ms) to every transition; ARES availability cannot be tied to operator-facing SLA
- Background queue / worker: over-engineered for week-1 data collection; revisit after data validates the need
ADR-003: record_id as optional derived wire alias of id
context
The tasks table has a single primary key id. CASA's Airtable adapter historically exposed the Airtable record id (e.g. recXXX) as recordId (camelCase), distinct from the task's logical id. When the control API shipped in eva-hq, the wire shape used record_id as an alias mapping to the same Airtable record identifier.
Consumers (board UI, admin routes) used recordId/record_id as the mutation key sent to transition endpoints.
decision
record_id is included in tasks.v1.json as an optional field (type: string, not in required) with a DEPRECATED alias of id; removed in v2 description. The list projection populates it as t.id (identity). CASA consumers fall back to ?? t.id at every usage site.
The field is classified as a derived_wire_fields exception in the verify script — it has no SQL column, which is intentional and documented.
consequences
Positive:
- CASA's board and transition routes continue to work without a server-side API change
- The deprecation signal is in-schema, making v2 removal discoverable
- The verify script explicitly allowlists
record_id so it never triggers a false drift alarm
Negative:
- Every CASA usage site requires
?? t.id ?? '' fallback due to optionality — slightly noisy but mechanically safe
- Two fields (
id and record_id) carry the same value during the transition period; cleaned up in v2
alternatives considered
- Make
record_id required in the contract: would break the Python TypedDict semantics (all fields total=False) and conflict with the policy that only id, title, status are required
- Remove immediately: would require a coordinated CASA API change before the contract could land; deferred to v2