Implementation Plan: Deterministic Spine (036)¶
ARCHIVED (2026-08-29). Part of the archived 036 spec — see the banner in
spec.mdand the successordocs/product-specs/040-holodeck-temporal/spec.md.Sources:
docs/product-specs/036-deterministic-spine/spec.md+refinements.md(binding decisions: Claude-only edges; human-node table computes a recommendation; newWorkflowTestExecutor; confidence gating cut; FEEL syntax is the contract;decided_byidentity). Plan produced 2026-06-10 after read-only codebase research and a FEEL-library landscape scan. Design input for T2/T3:../dmn-yaml-mapping.md(DMN ↔ YAML element mapping, DRD semantics, open design points,source:annotation proposal).
Overview¶
Build a new self-contained subsystem — src/holodeck/lib/workflow/ (engine),
src/holodeck/models/workflow.py + decision_table.py + run_record.py
(models), src/holodeck/cli/commands/workflow.py (CLI) — that runs a DAG of
determination nodes: edge nodes (agents behind schema gates) and workflow-level
input_data (facts of record) feed policy nodes (DMN tables + FEEL) feeding a
human node (CLI decision), with an OTel trace, a replayable local run record, and
a policy-test executor.
Anchor sample: Targeted Compliance Framework compliance determination
(retargeted 2026-07-25 from invented loan-hardship underwriting to real, public,
citable Australian policy — see spec.md US6). There is now an explicit MVP
ship line; see spec.md § "The MVP ship line" and todo.md.
Architecture Decisions¶
| Decision | Choice | Rationale |
|---|---|---|
| FEEL evaluator | Spike bkflow-feel (MIT, lark grammar) first; fallback pySFeel (GPLv3 — needs license sign-off) | Landscape scan: bkflow-feel covers the FR-010 subset standalone (verify date - date in spike); SpiffWorkflow has no FEEL parser and lacks FIRST/PRIORITY — ruled out. FEEL syntax stays the contract; subset may narrow (refinements §5). |
| Hit-policy logic | In-house table evaluation (UNIQUE/FIRST/PRIORITY), FEEL cells via the embedded evaluator |
We need exact control of no-match / UNIQUE-multi-match errors, version snapshots, and matched-rule reporting. (Spike may revisit if bkflow-dmn fits.) |
| Topological sort + cycle detection | stdlib graphlib.TopologicalSorter |
Python ≥3.10 floor; cycle detection for free; no new dependency. |
| Node model | Discriminated union following the ToolUnion pattern (src/holodeck/models/tool.py:916) with extra="forbid" |
Makes "AI output as a verdict" unrepresentable by construction (FR-005/SC-008). |
| Workflow schema publication | scripts/generate_workflow_schema.py + schemas/workflow.schema.json + sync test, mirroring generate_agent_schema.py / tests/unit/test_agent_schema_sync.py |
FR-004, existing convention. |
| Edge invocation | BackendSelector.select() → invoke_once(); gate validates ExecutionResult.structured_output via jsonschema |
Claude backend already produces/validates structured output; POC validates Claude only (refinements §1). |
| Human prompt | click.prompt/click.confirm (not InquirerPy) |
Scriptable with CliRunner(input=...) — required by US3's independent test. Wizard-style InquirerPy is not needed for a select-one prompt. |
| Run record | .holodeck/runs/<run-id>.json, canonical JSON (sorted keys), sha256 integrity over embedded table/gate snapshots |
Matches .holodeck/ conventions; canonical form makes "byte-identical replay" testable. |
| OTel | get_tracer() + start_as_current_span per node (pattern: agent_factory.py:161), gated on ObservabilityConfig |
FR-018, specs 018/022. Minimal spans land with the runner; full GenAI attributes are a dedicated task. |
| Errors | New WorkflowError family under holodeck.lib.errors (GateValidationError, TableEvalError, …) |
Repo standard; CLI exit-code mapping follows deploy.py:102 context-manager pattern. |
Dependency Graph¶
T1 FEEL spike
│
├────────────► T3 table model + FEEL wrapper ──► T3a provenance
│ │
│ └──► T4 hit policies ──────────────┐
│ │
T2 workflow models + schema ──► T2a input_data │
│ │ │
│ └──► T5 edge executor ─────────────────┤
│ ▼
└───────────────────────────────────► T6 runner + CLI run + review gate
│
T7 multi-level composition
│
T8 human node prompt
│
T10 run record ──► T11 replay
│
T13a "active months" spike
│
T14 TCF sample
═══════════════════════════════════════════════════════ MVP SHIP LINE
post-MVP: T9 draft · T12 tests · T13 OTel · T15 docs
The T6 → T7 edge still holds, but its meaning changed: T7's implementation landed inside T6 (the named-input resolver the runner needed gives multi-level composition for free). T7 now depends on T6 as tests depend on the code they cover, not as unbuilt work waiting on a prerequisite. See T7.
Parallelizable once Phase 1 lands: T12 (needs only T4), T13 (needs T6).
T9 is no longer a dependency of T14 — the MVP sample's human node ships with
no draft: block (permitted by FR-015a). T9 restores it post-MVP.
Task List¶
Phase 0 — De-risk (the one assumption that can invalidate the approach)¶
Task 1: FEEL evaluator spike¶
Description: Add bkflow-feel (uv add) and write a conformance test
exercising the full FR-010 subset: numeric comparisons, all four range bracket
forms, and/or/not(), string equality, list membership (in), date
literals, date comparison, date difference. Decide bkflow-feel vs pySFeel
(GPLv3 needs explicit sign-off) and whether bkflow-dmn replaces in-house
table logic. Record the decision + the exact supported subset in
docs/design-docs/036-deterministic-spine/research.md.
Acceptance criteria:
- [ ] Every FR-010 subset feature has a pass/fail verdict in research.md, including date(..) - date(..).
- [ ] A library is chosen with license noted; unsupported expressions enumerated (these become the static-rejection list in T3).
- [ ] If the subset must narrow, the narrowed FEEL-compatible subset is documented and the sample tables (T14) are noted as bending to it (refinements §5).
Verification: pytest tests/unit/workflow/test_feel_conformance.py -n auto -v
Dependencies: None · Files: pyproject.toml, tests/unit/workflow/test_feel_conformance.py, docs/design-docs/036-deterministic-spine/research.md · Scope: S
Phase 1 — US1: a single determination node end-to-end (P1)¶
Task 2: Workflow Pydantic models + published JSON schema¶
Description: src/holodeck/models/workflow.py: Workflow, node
discriminated union (EdgeNode = edge.agent + gate; PolicyNode =
decision + inputs + hit_policy; HumanNode = + requires_human,
optional draft/ai_may_draft: [reasons], optional decided_by),
extra="forbid". Model-level validation: unique ids, unresolved inputs
refs, cycle rejection via graphlib. Publish
schemas/workflow.schema.json via scripts/generate_workflow_schema.py +
sync test (mirror agent-schema pattern).
Acceptance criteria:
- [ ] Valid workflow YAML parses; cycles and unresolved refs raise ValidationError naming the offending ids (US2 scenarios 2–3, FR-003).
- [ ] SC-008 tests: an edge node declaring decision/hit_policy, or any field routing AI output to a verdict, is rejected by the schema (FR-005).
- [ ] Schema sync test passes (FR-004).
Verification: pytest tests/unit/models/test_workflow.py tests/unit/test_workflow_schema_sync.py -n auto · make type-check
Dependencies: None · Files: src/holodeck/models/workflow.py, scripts/generate_workflow_schema.py, schemas/workflow.schema.json, 2 test files · Scope: M
Task 3: Decision-table model + loader + FEEL subset wrapper¶
Description: src/holodeck/models/decision_table.py (DecisionTable,
Rule, required version label) loading tables/*.dmn.yaml relative to
workflow.yaml (ConfigLoader conventions, env substitution not needed).
src/holodeck/lib/workflow/feel.py wraps the chosen evaluator and statically
rejects out-of-subset expressions at table load with a precise locator
(table id, rule index, cell) — FR-010, edge case "FEEL evaluation error".
Acceptance criteria:
- [ ] A valid table loads with version; a missing version fails validation (FR-013 first half).
- [ ] Malformed FEEL or out-of-subset expression fails at load with table/rule/cell locator.
- [ ] Evaluator wrapper evaluates every in-subset expression class from T1's conformance list.
Verification: pytest tests/unit/workflow/test_decision_table.py -n auto
Dependencies: T1 · Files: src/holodeck/models/decision_table.py, src/holodeck/lib/workflow/feel.py, tests · Scope: M
Task 2a: Workflow-level input_data: (amends landed T2) — DONE (1d9d2eb)¶
Description: Add an optional workflow-level input_data: block to
src/holodeck/models/workflow.py: a mapping of name → {schema: <path>}
declaring typed facts supplied from outside the spine. holodeck workflow run
gains --input <payload.json>; every declared fact is JSON-Schema-validated
before any node executes, and a missing or invalid fact fails loudly.
input_data names resolve in a node's inputs: list and in table input
expressions identically to node verdicts. The model MUST make an agent-produced
input_data entry unrepresentable (no edge/agent field). Re-publish
schemas/workflow.schema.json via the generator + sync test.
Why an amendment: T2 is committed. The schema is soft now and hardens with
every task after T4 — this is the cheap moment (same reasoning that put source:
into T2 early).
Acceptance criteria:
- [x] Declared facts validate pre-execution; missing/invalid fails with a typed error naming the fact (FR-025).
- [x] input_data referenceable from inputs: and from table expressions (FR-026). Fact names resolve in inputs: but are excluded from the executed topological order — they are data, not nodes.
- [x] SC-010: schema-validation test proves no agent can produce an input_data value (FR-027) — asserted against the published workflow.schema.json, not only the Pydantic model.
- [x] Schema sync test passes.
Verification: pytest tests/unit/models/test_workflow.py tests/unit/test_workflow_schema_sync.py -n auto · make type-check
Dependencies: T2 · Files: src/holodeck/models/workflow.py, scripts/generate_workflow_schema.py, schemas/workflow.schema.json, tests · Scope: M
Task 3a: provenance: on decision tables (amends landed T3) — DONE (1d9d2eb)¶
Description: Add an optional non-executable provenance: block to
DecisionTable — generated_by, source, source_doc, source_sha256,
reviewed_by, reviewed_at. A hand-authored table omits it entirely.
provenance MUST NOT be referenceable from FEEL expressions or affect rule
matching (FR-029, FR-032). Enforcement of the review gate itself lands in T6.
Why now: spec 039 will emit generated tables. Without provenance the engine cannot distinguish an LLM-written table from a hand-written one, and the review gate (FR-030) has nothing to check.
Acceptance criteria:
- [x] Table with full provenance loads; table without it loads unchanged.
- [x] A FEEL expression referencing provenance.* is rejected at load with a locator. Unreachable by two independent routes: a reserved-root check at load, and provenance is never placed in the evaluation context. FR-032 survived 16 evasion attempts in audit.
Verification: pytest tests/unit/workflow/test_decision_table.py -n auto
Dependencies: T3 · Files: src/holodeck/models/decision_table.py, tests · Scope: S
Task 4: Hit-policy evaluation + conformance suite — DONE (1d9d2eb)¶
Description: src/holodeck/lib/workflow/table_eval.py:
evaluate(table, named_inputs) -> Verdict (value, matched rule index/id,
table version). Implements UNIQUE/FIRST/PRIORITY with standard DMN
semantics; loud TableEvalError on no-match (absent declared default) and on
UNIQUE multi-match (FR-011, FR-012).
Acceptance criteria:
- [x] SC-004 conformance suite passes: each hit policy, no-match (with and without default), UNIQUE multi-match.
- [x] Verdict carries matched-rule identity + table version (needed by record/replay and OTel).
Verification: pytest tests/unit/workflow/test_table_eval.py -n auto
Dependencies: T3 · Files: src/holodeck/lib/workflow/table_eval.py, tests · Scope: M
Task 5: Edge-node executor + schema gate — DONE (1d9d2eb)¶
Description: src/holodeck/lib/workflow/edge.py: load the node's agent
YAML (ConfigLoader.load_agent_yaml), invoke via BackendSelector.select() /
invoke_once(), validate ExecutionResult.structured_output against
gate.schema (JSON Schema file resolved relative to workflow.yaml) and emit a
GatedOutput. Free text (structured_output is None) or schema-invalid
output raises GateValidationError (FR-006/007/008). Claude-only per
refinements §1 — no SK structured-output work.
Acceptance criteria:
- [x] Valid structured output crosses; the gated object (not raw text) is the canonical value. The gate schema is snapshotted by content.
- [x] Free-text and schema-invalid outputs are rejected with a typed error naming node id + validation failure (US1 scenario 2, SC-003) — three distinct failure channels: output rejected / gate unusable / nothing produced to judge.
- [x] Tests use a mocked AgentBackend — zero live LLM calls; gate validation never touches the network.
Verification: pytest tests/unit/workflow/test_edge_gate.py -n auto
Dependencies: T2 · Files: src/holodeck/lib/workflow/edge.py, errors additions in holodeck/lib/errors, tests · Scope: M
Task 6: Workflow runner + holodeck workflow run (single level) — DONE (1d9d2eb), with one recorded gap¶
Description: src/holodeck/lib/workflow/runner.py: topo-order via
graphlib, execute edge nodes then policy nodes, hold per-node results,
minimal OTel span per node (get_tracer pattern, gated on observability
config). src/holodeck/cli/commands/workflow.py: workflow group +
run <workflow.yaml> subcommand registered in cli/main.py, error→exit-code
mapping per the deploy.py context-manager pattern, click.echo output.
Acceptance criteria:
- [x] US1 scenarios 1–2 pass as an integration-style test (mocked backend, CliRunner): valid edge output → table verdict echoed; invalid → exit non-zero with gate error. Exit codes separate misauthored / could-not-decide / gate-rejected / invocation-failed.
- [x] Load-time validation failures occur before any agent invocation (FR-003) — prepare_workflow performs all validation and constructs no backend, so FR-003 is structural rather than merely ordered. execute_workflow is the separate execution half.
- [x] Delivered T7's implementation as a side effect — _named_inputs gives multi-level composition at arbitrary depth. T7 is retitled to proof-and-cover work accordingly.
- [ ] ⚠ US1 scenario 3 / FR-018 NOT met. Workflow has no observability: block and is extra="forbid", so holodeck workflow run has nowhere to obtain an ObservabilityConfig and emits zero spans. execute_workflow accepts one and is unit-tested with an in-memory exporter, but the CLI path is unreachable end to end. To be closed by T13.
Verification: pytest tests/unit/cli/test_workflow_command.py tests/unit/workflow/test_runner.py -n auto · holodeck workflow run --help
Dependencies: T4, T5 · Files: src/holodeck/lib/workflow/runner.py, src/holodeck/cli/commands/workflow.py, src/holodeck/cli/main.py, tests · Scope: M
Checkpoint 1 — US1 complete — PASSED 2026-07-25¶
-
make format && make lint && make type-checkclean;pytest tests/unit -n autogreen — 5298 passing, 4 skipped; security and pre-commit also clean. Fullmake cire-run green at checkpoint. - A single edge→policy workflow runs via CLI with a mocked agent — and live, via
sample/pbas-points/: free text classified by Claude, gate-validated, thentables/points.dmn.yamlrule 10 awarded 20 pointsper_week. The determination came from the table, not the model (SC-003, FR-005 demonstrated rather than asserted). - Human review before Phase 2 — done by the maintainer, who ran the live workflow.
Phase 2 is unblocked. Carried forward as known-incomplete, not silently: FR-018
unreachable from the CLI (see T6, closed by T13); format: date → datetime.date
conversion absent (T13a prerequisite); no adversarial pass has yet audited the
seams between tasks — each was audited in isolation, and both of the worst
findings so far (the != silent verdict, remote $ref fetching) lived at
boundaries.
Phase 2 — US2: composed determination levels (P1)¶
Task 7: Multi-level composition — prove and cover¶
The implementation landed inside T6. Do not re-implement it. Building the runner required
runner._named_inputs, which resolves each declared input name to a fact of record, an upstream edge node's gated object, or an upstream policy node'sVerdict.outputs. Once that exists, arbitrary depth follows fromgraphlib. T7's original deliverable — "nodea's verdict (or gated object) is available as variableain dependent tables' FEEL context (FR-009, FR-016)" — is therefore already satisfied. Verified empirically against the committed code, unmodified: a 3-edge → 2-policy → 1-policy DAG executes in order('a','b','c','mid1','mid2','top')and composes correctly, with nodebfeeding both mid-level nodes (a diamond, not a tree) and the top table dot-pathing into upstream verdicts (mid1.band).
Description: What remains is proof, not build. Nothing currently defends
multi-level composition: there is no test_composition.py, and test_runner.py
covers only single-level DAGs. That is a live risk, not a theoretical one — the
recent audit found that flipping all() to any() in the hit-policy matcher
survived mutation testing entirely, because correct code with no test defending
it is indistinguishable from broken code. Composition is in exactly that state
today. Write the 3→2→1 integration test (mocked edges, diamond shape, dot-paths
into upstream verdicts), close the CLI-level gap on US2 scenario 3, and add the
sample's second level.
Acceptance criteria:
- [ ] US2 scenario 1 covered by a 3-edge → 2-policy → 1-policy integration test with mocked edges: evaluation order is a valid topological sort, each higher node receives lower nodes' verdicts as named inputs, and the final verdict reflects the composed sub-verdicts. Use the diamond shape (one edge feeding two mid-level nodes) and at least one dot-path into an upstream verdict — a tree with scalar inputs under-tests the resolver.
- [ ] US2 scenario 3 re-asserted through the CLI: an unresolved inputs reference → clean error, exit code 2, no agent invoked. (Scenario 2, the cycle, is already covered end to end by tests/unit/cli/test_workflow_command.py::test_cycle_exits_two_without_invoking_an_agent; unresolved references are covered only at model and prepare_workflow level.)
- [ ] Sample-proof (blocking): sample/pbas-points/ demonstrates a genuine two-level DRD it cannot demonstrate today. Footnote (1) of the pinned source — "These tasks and activities are available to Workforce Australia Services participants only" — is an eligibility precondition attached to rows, not a points rule, and is currently encoded inline as rules 5/6 of tables/points.dmn.yaml. Extract it into tables/eligibility.dmn.yaml, keyed on activity.activity_type + the existing participant.stream fact, whose verdict becomes a named input to points_award. This is a refactor plus a level: the verdict for every existing case — in particular drivers_licence_attainment + workforce_australia_online → 0 points / not_available — must be unchanged. README's "What is modelled" table updated to name the new decision.
Source note (verified against the PDF, p.2). Footnote (1) covers exactly one of the eleven modelled activities (driver's licence attainment), so the eligibility table restricts in one direction only. Footnote (2) is the mirror case — "available to Workforce Australia Online participants only" (online learning modules; Career coaching – Youth Advisory Sessions), neither currently in the taxonomy. Adding one is optional scope; without it the level is real but thin, which is honest rather than wrong.
Verification: pytest tests/unit/workflow/test_composition.py tests/unit/cli/test_workflow_command.py -n auto · holodeck workflow run sample/pbas-points/workflow.yaml --input sample/pbas-points/case.json
Dependencies: T6 · Files: tests/unit/workflow/test_composition.py + fixture YAMLs, tests/unit/cli/test_workflow_command.py, sample/pbas-points/**. No runner.py change is expected — if one turns out to be needed, that is a defect the new test found, which is the point. · Scope: S
Checkpoint 2 — composition proven¶
- Full DAG executes bottom-up with named inputs; CI targets clean.
Phase 3 — US3: human-accountable determination (P1)¶
Task 8: Human node — recommendation + CLI decision prompt¶
Description: Per refinements §2/§6: at a requires_human node the runner
evaluates the node's table to produce a recommendation (+ matched rule),
presents composed inputs + recommendation at the CLI, displays declared
decided_by and asks the operator to confirm/enter their name, then
click.prompts a selection among the table's declared outputs. Override
(human ≠ recommendation) is flagged. Abort (Ctrl-C / EOF) → run ends with
explicit "no determination" status, nothing recorded as final (edge case).
Acceptance criteria:
- [ ] US3 scenarios 1 & 3 pass via CliRunner(input=...): pause + presentation; verdict = human choice, attributed to confirmed decided_by name with timestamp.
- [ ] Recommendation and override flag are captured in the node result (feeds T10).
- [ ] Abort path terminates with "no determination" and non-zero exit.
- [ ] Sample-proof (blocking): sample/pbas-points/ gains a requires_human node modelling the source's activity-bonus Note (PDF p.2): "Providers and the Digital Services Contact Centre (DSCC) may increase the values of certain tasks or activities through an activity bonus to reflect the individual circumstances of the participant and the task or activity they are doing." corpus-manifest.md names this the corpus's canonical unmappable clause — pure discretion, where a table must refuse to decide. The node takes points_award as its recommendation and a named decided_by delegate confirms it or records that a bonus applies. README item 2 of "What is deliberately NOT modelled" moves from excluded to routed to a human, and the run is scripted end to end with CliRunner(input=...).
This is the sample's most important framing point: the human node exists because the source says a rule cannot. Two constraints come from the document, not from design taste: 1. The Note authorises an increase only — never a reduction. "Confirm or adjust" would overstate it. 2. It states no amount and no criteria. No table may compute a bonus value, so the delegate's act is a categorical selection among the table's declared outputs (per FR-014), not free numeric entry. A sample that let the operator type an arbitrary points figure would be inventing a mechanism the source does not contain.
Transport: build the CLI only, but do not foreclose serve¶
holodeck serveintegration and a task-inbox HITL UI are both Out of Scope (spec.md). Build the CLI prompt and nothing else. Two constraints exist only to stop that choice becoming a dead end.1. Keep
clickout of the runner. One narrow seam: the runner asks something for a decision and records what came back. It must return the selected output,decided_by, a timestamp, and be able to say aborted / no determination. CLI is the only implementation built now.Stated tension: a Protocol with a single implementation is speculative under CLAUDE.md §2 ("no abstractions for single-use code"). It is justified here only because the alternative — a runner importing
click— is a known dead end rather than a hypothetical one, and the seam is a few lines. If it grows past that, it has become the thing the rule warns about. Do not add a registry, a plugin point, or a second implementation.2. If serve is ever wired: the runner emits the AG-UI events, never a model. AG-UI can carry this as a frontend
prompt_usertool call — the mechanism atserve/protocols/agui.py:782, where a run resumes from atoolmessage. But in the normal AG-UI flow the model emits the tool call and the result returns to the model's context. Routing a human node that way would put Claude between the delegate and the verdict, free to paraphrase, drop, or re-interpret the determination. That is precisely the invariant this spec exists to hold.Nothing in AG-UI requires a tool call to originate from an LLM — they are events on a stream. So the runner emits
ToolCallStart/Args/Endforprompt_useritself, awaits thetoolmessage, and writes the decision straight into the verdict. AG-UI stays pure transport and no model is in the loop. The obvious implementation is the wrong one; record this before anyone reaches for it.3. Durability is T10's problem, and it is nearly free. A frontend tool round-trip only works while someone is watching the stream; a delegate deciding tomorrow needs the run to suspend, which an SSE connection cannot hold. The run record is already the resume token — T11 replay reconstructs every verdict up to the human node from snapshots with zero LLM calls, which is exactly what resume requires. The only addition is a record whose status is
awaiting_humanwith the decision absent. So suspend/resume is additive over T10+T11, not a redesign — a reason to keep them ahead of T14. Build none of it in T8; just do not design a decision seam that cannot later be satisfied by a value arriving from somewhere other than a terminal.
Verification: pytest tests/unit/workflow/test_human_node.py -n auto · scripted holodeck workflow run sample/pbas-points/workflow.yaml --input sample/pbas-points/case.json
Dependencies: T7 · Files: src/holodeck/lib/workflow/human.py, runner.py, cli/commands/workflow.py, tests, sample/pbas-points/** · Scope: M
Task 9: Draft agent advisory path (ai_may_draft: reasons) — POST-MVP¶
Documented here beside T8 for cohesion, but it sits after the ship line. The MVP sample's human node has no
draft:block (FR-015a permits it), so T14 no longer depends on this. T9 restores the block post-MVP.
Description: When a human node declares draft, invoke the drafting agent
(via BackendSelector, mocked in tests) to produce only the fields in
ai_may_draft (POC: reasons); show as clearly-advisory text before the
prompt; record as advisory context (FR-015a). Schema already prevents AI
output reaching the verdict (T2); add an explicit test.
Acceptance criteria:
- [ ] US3 scenario 2: reasons draft displayed as advisory; no AI field can populate the verdict (schema-validation test, SC-008).
- [ ] Human node without draft presents inputs only.
- [ ] Sample-proof (blocking): sample/pbas-points/'s human node gains a draft: block with ai_may_draft: [reasons], producing advisory reasons bearing on whether an activity bonus is warranted — never the points value and never a bonus amount. The source states no amount, so a drafted figure would be invented policy; the README says so explicitly.
Verification: pytest tests/unit/workflow/test_draft_agent.py -n auto
Dependencies: T8 · Files: human.py, tests · Scope: S
Checkpoint 3 — bright line proven¶
- Scripted end-to-end: 3 edges → 2 policies → human decision with draft reasons, all mocked, deterministic.
- Human review before Phase 4.
Phase 4 — US4: record & replay (P2)¶
Task 10: RunRecord model + persistence¶
Description: src/holodeck/models/run_record.py + writer in
src/holodeck/lib/workflow/record.py: persist to .holodeck/runs/<run-id>.json
(canonical JSON, sorted keys): gated edge outputs, per-policy matched rule +
verdict, content snapshots of every table and gate schema used (version
labels + sha256), human recommendation/decision/override/decided_by,
advisory drafts, timestamps (FR-013, FR-019).
Acceptance criteria:
- [ ] A completed run writes a record sufficient for replay with the tables/ and schemas/ dirs deleted.
- [ ] Record round-trips through the Pydantic model; snapshot hashes verify.
- [ ] Sample-proof (blocking): a sample/pbas-points/ run writes .holodeck/runs/<id>.json carrying the validated input_data facts (participant, claim), the gated activity classification, the matched rule of each policy node (eligibility and points award), the delegate's decision and override flag, and content snapshots of both tables and the gate schema with their provenance blocks (source, source_doc, source_sha256) and sha256 integrity.
Verification: pytest tests/unit/workflow/test_run_record.py -n auto · inspect .holodeck/runs/ after a PBAS run
Dependencies: T8 · Files: src/holodeck/models/run_record.py, src/holodeck/lib/workflow/record.py, runner.py, tests · Scope: M
Task 11: holodeck workflow replay <record>¶
Description: Replay re-evaluates policy + human layers from the record's snapshots and recorded human decision; never constructs a backend (FR-020/021). Integrity failure (missing/corrupt snapshot, hash mismatch) fails loudly with no fallback to on-disk tables.
Acceptance criteria:
- [ ] US4 scenarios 1–3: identical verdict + matched rules (byte-identical canonical JSON comparison, SC-002); zero LLM calls (assert BackendSelector never invoked); edited/missing on-disk tables don't affect replay; corrupt record → loud failure.
- [ ] Sample-proof (blocking): the sample/pbas-points/ run record replays to an identical verdict with sample/pbas-points/tables/ and sample/pbas-points/schemas/ deleted from disk and zero LLM calls. Demonstrated as a copy-pasteable sequence in the sample README.
Verification: pytest tests/unit/workflow/test_replay.py -n auto · holodeck workflow replay .holodeck/runs/<id>.json with the PBAS tables/ + schemas/ dirs moved aside
Dependencies: T10 · Files: src/holodeck/lib/workflow/replay.py, cli/commands/workflow.py, tests · Scope: M
Checkpoint 4 — "prove it" property¶
- Run → record → replay loop demonstrated in tests; CI targets clean.
Phase 6 — US5: policy-as-code testing (P3 — POST-MVP, parallelizable after T4)¶
Task 12: WorkflowTestExecutor + holodeck workflow test¶
Description: Per refinements §3: a new executor mirroring the existing
test framework's case/result shapes (TestCaseModel-like YAML cases,
TestResult-like outputs) that evaluates a decision table over given input
rows — no agent, no backend, no LLM. CLI subcommand
holodeck workflow test <policy-tests.yaml> with expected-vs-actual diff
output on failure (FR-023, SC-006).
Acceptance criteria:
- [ ] US5 scenarios: pass iff table yields expected verdict; rule edit → failing test with expected-vs-actual diff.
- [ ] No TestExecutor refactor; existing test framework untouched.
- [ ] Sample-proof (blocking): sample/pbas-points/ carries a committed policy-test file over tables/points.dmn.yaml, covering at minimum (a) the threshold tier boundary at exactly 15 contact hours — "contact hours up to 15 hours per week" is inclusive, so 15 must yield 15 points, not 20 — and (b) an activity_type the table does not cover, asserting the loud no-match (TableEvalError, no silent zero). Case (b) is only reachable through the policy tester: the gate's closed enum and the table's rules cover the same eleven activities, so no run can produce it. That is the point of SC-006.
Verification: pytest tests/unit/workflow/test_policy_executor.py -n auto · holodeck workflow test sample/pbas-points/<policy-tests>.yaml
Dependencies: T4 · Files: src/holodeck/lib/workflow/policy_test.py, cli/commands/workflow.py, models for policy test cases, tests, sample/pbas-points/** · Scope: M
Phase 7 — Observability completion (POST-MVP)¶
Task 13: Full OTel GenAI span attributes¶
Description: Upgrade the minimal spans from T6 to specs-018/022-conformant
attributes: per-node span with node id/kind, named inputs, table version,
matched rule(s), verdict; gate-rejection events; replay spans marked as replay.
Respect ObservabilityConfig enable/disable; reuse RedactingSpanProcessor
pipeline.
Acceptance criteria:
- [ ] FR-018: in-memory-exporter test asserts span names + attributes for edge, policy, human nodes.
- [ ] Observability disabled → zero spans, zero overhead path.
- [ ] Closes T6's recorded gap: holodeck workflow run must be able to obtain an ObservabilityConfig at all. Today Workflow is extra="forbid" with no observability: block, so the CLI path emits zero spans however well the runner is instrumented. Whatever shape this takes (a workflow.yaml block, a CLI flag, or project config) it re-publishes workflow.schema.json and its sync test.
- [ ] Sample-proof (blocking): a sample/pbas-points/ run emits spans through the CLI carrying node id and kind, table version, matched rule, and each table's provenance — captured against an in-memory exporter, not asserted by hand.
Verification: pytest tests/unit/workflow/test_otel_spans.py -n auto
Dependencies: T11 · Files: runner.py, replay.py, possibly lib/observability constants, tests · Scope: S
Phase 5 — US6: anchor sample (P1 — the MVP's proof)¶
Task 13a: "Active months" FEEL spike (before T14)¶
Description: Resolve spec Open Question 5. TCF expires demerits after "6
active months" and resets after "3 active months compliant" — a count of
months in which the participant was active, not elapsed calendar time, so it
is not a date difference. Determine whether it is expressible via
inputs[].expression, or must be pre-computed and supplied as input_data.
Recall research.md caveat 6: date(variable) does not parse; date-typed
fields cross the gate as datetime.date and subtract bare, yielding a
timedelta that caveat 1 requires converting to days.
Prerequisite the spike must record.
research.mdcaveat 6'sformat: date→datetime.dateconversion does not exist.input_datastores the raw JSON value, so adate-typed table column receives a string and bare subtraction raises. T13a and T14 both depend on date arithmetic, so the spike's verdict must say who converts and where.
Acceptance criteria:
- [ ] A written verdict in research.md: expressible, or pre-computed into input_data with the shape specified.
- [ ] If the FEEL subset must narrow further, it is documented and SC-005 is amended (refinements §5: the sample bends to the subset, never the reverse).
- [ ] The date-conversion gap above is either closed or explicitly assigned to T14.
- [ ] Sample-proof: none, deliberately. This task is a written verdict, not code, and sample/pbas-points/ has no date arithmetic to extend. Do not invent a sample change for it.
Dependencies: T3 · Files: research.md, a conformance test · Scope: S
Task 14: Targeted Compliance Framework sample¶
Description: sample/tcf-compliance/ — one mutual-obligation compliance
event under the Australian TCF, modelled as a state transition:
input_data: prior compliance state (zone, active demerits + accrual dates, prior penalty count, months compliant) with a JSON Schema. Never LLM-touched.- One edge node: an agent (anthropic provider — Claude-only POC) assessing the participant's stated reason against the enumerated factors of the Reasonable Excuse Determination 2018 s5(2)(a)–(j), across a schema gate derived from that closed list.
tables/zone.dmn.yaml(UNIQUE) — next zone from prior state + this event.tables/penalty.dmn.yaml(FIRST) — escalation: 1 week → 2 weeks → cancellation.- A
requires_humannode withdecided_by(delegate) and nodraft:block (T9 is post-MVP; FR-015a permits this). - Tables bend to the T1-verified FEEL subset and T13a's verdict.
README.mdcarrying the framing statement required by US6 — not optional.
Source documents are pinned in docs/product-specs/039-policy-generator/corpus/ with hashes
in corpus-manifest.md; each table's provenance.source cites its authority.
Acceptance criteria:
- [ ] US6 scenarios: end-to-end run composes zone + penalty verdicts and the human decision; replay reproduces identically with zero LLM calls.
- [ ] US6 scenario 3: no agent produces any input_data fact (SC-010).
- [ ] An integration test (marked @pytest.mark.integration) or documented quickstart demonstrates SC-007; unit-level path uses mocked edges.
- [ ] README states the framing up front.
- [ ] Sample-proof (blocking): sample/pbas-points/ still runs and replays after T14 lands, with every feature T7–T11 added to it intact. TCF is a second sample, not a replacement — the demonstrator must not rot while attention is on the anchor.
T14 must be rescoped before it starts —
sample/is deliberately untracked. Decided 2026-07-25:.gitignore:45keeps/sampleignored repo-wide and samples stay local-only (Open Question 4). Sotests/integration/test_tcf_sample.pycannot exist as written — it would reference untracked paths and fail on a fresh clone. Pick one before starting: move the assertions onto committed fixtures undertests/fixtures/workflow/, or make the sample proof a documented quickstart run by hand. SC-007 permits the latter ("demonstrated in CI or a documented quickstart"), so the criterion is still satisfiable — just not automatically verifiable.
Verification: holodeck workflow run sample/tcf-compliance/workflow.yaml --input case.json (scripted input) · sample proof per the rescoping note above — not tests/integration/test_tcf_sample.py as originally specified
Dependencies: T11, T13a · Files: sample/tcf-compliance/** (YAML/JSON/MD), one integration test · Scope: M (mostly YAML)
▲ MVP SHIP LINE ▲¶
MVP = T4, T5, T6, T7, T8, T10, T11, T14 plus amendments T2a, T3a and spike T13a. Delivers SC-001…SC-005 and SC-007…SC-010; only SC-006 (a table tested in isolation, needs T12) falls past the line.
Two samples, two jobs¶
sample/pbas-points/ is the running demonstrator: it exists from T6 onward
and grows one feature per task, so every task above T6 carries a blocking
sample-proof criterion naming what PBAS must newly demonstrate. A task is not
done while the demonstrator cannot show its feature. sample/tcf-compliance/
(T14) is the anchor MVP proof — real statute, prior-state transition, human
delegate, a gate derived from an enumerated legal list — and lands once,
complete. It is not retargeted onto PBAS and its scope is not weakened by
anything PBAS proves. Full statement of the convention: todo.md.
Phase 8 — Documentation (POST-MVP)¶
Task 15: Documentation¶
Description: docs/ page: workflow concepts (spine, gates, determination
nodes, the one rule), authoring reference for workflow.yaml /
tables/*.dmn.yaml, CLI usage (run/replay/test), replay guarantees and
their limits (POC ≠ legal-grade audit store). Update AGENTS.md pointers if
conventions require.
Acceptance criteria:
- [ ] Quickstart reproduces the sample run + replay verbatim.
- [ ] The "LLM is never the spine" invariant and POC scope-of-claim are stated.
- [ ] Sample-proof (blocking): the quickstart reproduces the sample/pbas-points/ run and replay verbatim — the commands copy-paste unmodified and the printed verdict matches what the page claims. Executed once as part of verification, not assumed.
Verification: Manual doc review; quickstart commands executed once.
Dependencies: T14 · Files: docs/workflows.md (or docsite equivalent) · Scope: S
Checkpoint 5 — feature complete¶
- All SC-001…SC-010 demonstrably met (map each to its test in the PR description).
-
make ciclean. Human review / PR.
Successor: 039 policy generator¶
docs/product-specs/039-policy-generator/ — AI-drafted decision tables and workflow.yaml
DRDs from policy documents, with TODO markers where edge nodes go. Specced in
parallel with this work so its schema needs land here as additions (T2a, T3a)
rather than as migrations later; built only after this spec's MVP ships.
036 ships the two things that make a drafted table safe to run — the
provenance block (T3a) and the review gate (T6) — and nothing else about
generation. The golden corpus is already pinned:
docs/product-specs/039-policy-generator/corpus-manifest.md.
Risks and Mitigations¶
| Risk | Impact | Mitigation |
|---|---|---|
| bkflow-feel gaps (date difference unverified; no duration literals; dormant since 2023) | High — invalidates FR-010 approach | T1 spike first; pin or vendor (MIT allows); fallback pySFeel only with GPLv3 sign-off; last resort: narrow the subset (refinements §5 forbids non-FEEL syntax) |
| Byte-identical replay brittleness (float/dict-order/timestamp drift) | Med | Canonical JSON (sorted keys), timestamps excluded from the compared verdict payload, sha256 snapshot integrity |
| Human prompt untestable | Med | click.prompt + CliRunner(input=...); no InquirerPy on this path |
structured_output quirks on the live Claude backend (vs mocks) |
Med | Sample integration test (T14) is the live check; unit suite fully mocked |
| Scope creep toward a workflow engine | Med | Out-of-Scope list is binding; any events/timers/callbacks proposal is rejected in review |
| OTel attribute bloat / PII in spans | Low | Reuse RedactingSpanProcessor; record full payloads only in the run record, not spans |
Open Questions (need human input before/at execution)¶
- ~~GPLv3 tolerance if bkflow-feel fails the spike~~ — moot; T1 chose bkflow-feel (MIT).
- Policy-test verb: plan assumes
holodeck workflow test <file>. Confirm vs e.g.holodeck test --policy. - ~~Sample model choice for the edge agents~~ — settled:
anthropic/claude-opus-5,temperature: 0.0(sample/pbas-points/agents/activity-classifier.yaml), run end to end successfully. - ~~
sample/is not tracked by git~~ — DECIDED 2026-07-25: leave ignored, defer..gitignore:45keeps/sampleignored repo-wide; samples remain local-only and.gitignoreis not to be changed. Revisit only with a concrete reason. Binding consequences: every sample-proof criterion in this plan is a manual check, never a CI gate — report them as such rather than implying a pipeline confirmed them; T14 needs rescoping before it starts (see the note under T14 —tests/integration/test_tcf_sample.pycannot reference untracked paths); and SC-007 keeps only its documented-quickstart limb, remaining satisfiable but not automatically verifiable.
Execution Notes¶
- Always
source .venv/bin/activate; runmake format && make lint && make type-checkafter each task (CLAUDE.md). - Tests:
pytest -n auto, AAA,@pytest.mark.unitdefault; live-LLM only in T14's integration test. - Conventional commits, no Claude attribution; branch
036-deterministic-spine.