Phase 1 — Make Production Claims True: Implementation Tickets
Document ID: RM-AIM-P1
File Path: docs/18-roadmap/v1.1/phase1-production-truth-tickets.md
Version: 1.0.0
Status: Done — every ticket complete except WFL-104 (seq-safety done; lease-token fencing deferred, documented in the ticket)
Owner: Engineering (AI Core / Platform)
Last Updated: 2026-07-14
Purpose
Section titled “Purpose”Phase 1 of PRD-004 §8 — the fixes that make features the platform already advertises actually work: real cost accounting, context/token management, activated sandboxing, graceful/durable server behavior, a production-grade Postgres path, and a reconciled release/CI story.
Covers workstreams WS-A (AI core), WS-B/R-B.1 (cost), WS-E (sandbox activation), WS-G (server durability), WS-H (workflow Postgres core), WS-I/R-I.3 (secret default), WS-J (release/CI), WS-K (UI security/tests).
Format matches RM-GA-P2: problem + file:line evidence, change, acceptance criteria, files, size (S ≤2d, M 3–5d, L 1–2w), deps, priority.
Sequencing at a glance
Section titled “Sequencing at a glance”PRV-101 (cost table) ── LAND FIRST (unblocks real quota enforcement everywhere)AIC-101 (context/token mgmt) ─┬─ independent AI-core workAIC-102 (parallel tools) ├AIC-103 (max_steps default) ┘SBX-101 (wire strong sandboxes) ── SBX-102 (Windows Job Object) [parallel]SRV-101 (graceful shutdown) ─┬─ server durability (parallel)SRV-102 (durable async runs) ├SRV-103 (webhook outbox) ├SRV-104 (API-key lifecycle) ┘WFL-101 (pg pool) ─┬─ WFL-103 (pg TLS) ── workflow Postgres hardeningWFL-102 (subwf depth) │WFL-104 (fenced seq) ┘SEC-101 (encrypted secret default) ─ independentDX-101 (version+CHANGELOG) ── DX-102 (release automation) ── DX-103 (dashboard+Windows CI)UI-101 (token storage) ─ UI-102 (UI tests) [parallel]Land PRV-101 first. Until real cost flows, per-project quotas (PRD-003) admit everything at $0 — so PRV-101 is a prerequisite for trusting any Phase-2 quota work.
WS-B — Provider & Cost
Section titled “WS-B — Provider & Cost”PRV-101 [P0] — Per-model price table + real cost_usd — DONE (2026-07-09)
Section titled “PRV-101 [P0] — Per-model price table + real cost_usd — DONE (2026-07-09)”Problem. OpenAiProvider hardcodes cost_usd = 0.0
(crates/wovyr-provider/src/openai.rs:442,513-515) and mistralrs likewise
(mistralrs_provider.rs:197-203); the only price constant is the mock’s synthetic
MOCK_USD_PER_TOKEN (mock.rs:16). Every CostEvent, and the server’s per-project
budget accounting fed from output.usage.cost_usd (wovyr-runtime/src/lib.rs:257),
sees $0 in production — so llm_cost_per_day_usd quota enforcement (PRD-003) is
a no-op. (PRD-004 R-B.1; audit High.)
Change.
- Add a per-model
$/input-token+$/output-tokenprice table (aPriceBook), sourced from config with sane built-in defaults for common OpenAI/Anthropic models and overridable via env/config file. - Compute
cost_usdfrom returned tokenUsageinOpenAiProvider(and mistralrs = $0 local is legitimate, but make it explicit/documented). - Log the computed cost before enforcement (a one-release “observe then enforce” rollout, per PRD-004 §10 risk note).
Acceptance criteria.
- A unit test asserts a known token count × known price = expected
cost_usd. - An integration test drives a run and asserts the project’s daily-cost accumulator advances by the computed amount (not 0).
- Unknown-model lookups fail safe (documented default or a loud warn), never panic.
Files. crates/wovyr-provider/src/{openai.rs,mistralrs_provider.rs,gateway.rs}, a
new pricing.rs; crates/wovyr-server/src/tenancy.rs (quota assertions).
Size. M. Depends on: none. Blocks: SRV-202 (token quotas), EVL-202.
Implementation notes (2026-07-09). New wovyr-provider::pricing module: a
PriceBook (model → ModelPrice { input_per_1m, output_per_1m }) with built-in
defaults for common OpenAI/Anthropic models, overridable via WOVYR_MODEL_PRICES
(inline JSON) or WOVYR_PRICEBOOK_FILE (a JSON file); lookup is
exact-match-then-longest-prefix (so gpt-4o-mini-2024-07-18 → gpt-4o-mini), then
a configurable default, then a one-time tracing::warn! returning $0 for a
genuinely unknown/undefaulted model (fail-safe, never a panic). OpenAiProvider
carries a PriceBook (from PriceBook::from_env() by default; with_price_book
override) and computes cost_usd from returned Usage in both parse_response
(non-streaming) and StreamAccumulator::finish (streaming), logging it at
debug (target: "wovyr.pricing") — the “observe” half of observe-then-enforce.
mistral.rs keeps $0 with an expanded comment: a local model has no vendor bill,
so that’s the correct cost, not a placeholder. Acceptance:
pricing::tests::known_token_count_times_price_is_expected_cost +
openai::tests::parses_text_completion (now asserts the computed non-zero cost)
cover the unit criterion; tenancy::tests::priced_run_cost_advances_the_daily_ accumulator drives a price-book-computed cost through record_run_cost and
asserts the daily accumulator advances by exactly that amount (not $0);
pricing::tests::unknown_model_without_default_is_free_not_a_panic covers
fail-safe. MOCK_USD_PER_TOKEN is unchanged (the mock already priced its output).
WS-A — AI Core Runtime
Section titled “WS-A — AI Core Runtime”AIC-101 [P0] — Context-window management: tokenizer + history compaction — DONE (2026-07-09)
Section titled “AIC-101 [P0] — Context-window management: tokenizer + history compaction — DONE (2026-07-09)”Problem. run_agent clones the entire message history into every request
(crates/wovyr-agent/src/runtime.rs:240 messages.clone()) and appends
assistant+tool-result messages every tool turn (runtime.rs:259-270) with no
budgeting, truncation, or summarization. There is no tokenizer anywhere (the
mock estimates chars/4, mock.rs:33). A long tool loop silently blows the model’s
context window and cost. (PRD-004 R-A.1; audit High.)
Change.
- Add a token-counting utility (a real tokenizer, e.g.
tiktoken-style for OpenAI-family, with a documented fallback estimate for others). - Before each
ChatRequest, run a token-budgeted compactor: keep the system prompt- latest user turn + most-recent tool turns; drop-oldest first, then optionally summarize dropped turns. Strategy configurable; default lossless drop-oldest.
Acceptance criteria.
- A test with a synthetic long tool loop asserts the request token count stays under a configured budget while preserving the system + latest user turn.
- Compaction is deterministic given the same input (house determinism rule).
Files. crates/wovyr-agent/src/runtime.rs; new token-count util (in
wovyr-provider or wovyr-common). Size. L. Depends on: none.
Blocks: EVL-203.
Implementation notes (2026-07-09). Two new modules. wovyr-provider::tokenizer:
a TokenCounter trait with a dependency-free, deterministic HeuristicTokenizer
default (each whitespace-delimited chunk → ~4-char subword tokens + one per
punctuation char; a count_message helper adds role/tool-call framing overhead via
PER_MESSAGE_OVERHEAD/PER_TOOL_OVERHEAD). Deliberately not a real BPE encoder
— a bundled vocab is a heavy dep and this workspace builds offline; documented as a
~10–20% estimate suitable for budgeting (not billing — real cost is PRV-101’s
provider usage). A real tokenizer drops in behind the trait later. wovyr-agent:: context: compact(messages, tools_overhead, policy, counter) drops the oldest
tool rounds first (an assistant tool-call message + its tool results, kept
whole so the wire sequence stays valid) while always preserving the leading system
prompt(s) + first user turn; ContextPolicy { max_prompt_tokens, strategy } with a
generous 96k default (so short runs are untouched, back-compat) and a DropOldest
strategy, wired into RunOptions (with_context_policy) and applied at the top of
every run_agent loop iteration (logged at debug, target: "wovyr.context").
Acceptance: context::tests::long_tool_loop_stays_under_budget_and_keeps_system_ and_user + the through-run_agent integration test runtime::tests::long_tool_ loop_request_stays_under_context_budget (a scripted 30-round tool loop asserts the
largest request the provider ever saw stayed under the budget and the system+user
turns were present on every request); context::tests::compaction_is_deterministic
covers the house determinism rule; retained_rounds_stay_coherent proves no
dangling tool result survives.
AIC-102 [P0] — Concurrent tool-call execution within a turn — DONE (2026-07-09)
Section titled “AIC-102 [P0] — Concurrent tool-call execution within a turn — DONE (2026-07-09)”Problem. When the model requests multiple tool calls in one turn, they run
one-at-a-time in an awaited for loop (runtime.rs:261-270); parallelizable I/O-bound
tools serialize. (PRD-004 R-A.2; audit High.)
Change. Execute independent tool calls concurrently (join_all/JoinSet),
preserving result ordering by call id when feeding results back to the model.
Acceptance criteria. A test with two artificially-delayed tools asserts wall-clock ≈ max(individual), not sum; result ordering is stable/deterministic.
Files. crates/wovyr-agent/src/runtime.rs. Size. M. Depends on: none.
Implementation notes (2026-07-09). execute_tool_call was refactored to not
touch the &mut sink (it now returns a ToolOutcome { result_text, ok }), so the
whole batch can execute on one task via futures::future::join_all — no Send/
spawn requirement, so no threading of a shared sink. The loop emits every ToolCall
event up front (deterministic order), joins the batch concurrently, then emits each
ToolResult and pushes each Message::tool_result in input order — join_all
returns results positionally regardless of completion timing, so the history fed
back to the model is deterministic. Chose join_all over JoinSet deliberately:
JoinSet requires 'static + Send futures (forcing owned clones of def/registry
or an Arc refactor), whereas these tool futures only borrow & state and share no
mutable data, so on-task concurrency is both sufficient and simpler. Acceptance:
runtime::tests::independent_tool_calls_run_concurrently_with_deterministic_order
(a #[tokio::test(start_paused = true)] with a 300ms + 100ms SleepyTool pair;
asserts paused wall-clock ≈ 300ms = max, definitively under the 400ms a serial loop
would take, and that results feed back as [slow, fast] = call order, not the
[fast, slow] completion order). Added tokio time+test-util dev-features for
the paused clock (same pattern as wovyr-provider’s hedging tests).
AIC-103 [P1] — Apply manifest max_steps as the default budget — DONE (2026-07-09)
Section titled “AIC-103 [P1] — Apply manifest max_steps as the default budget — DONE (2026-07-09)”Problem. spec.max_steps is parsed (definition.rs:56-57) but run_agent_inner
reads only opts.max_steps (runtime.rs:239); only wovyr-runtime wires it
(lib.rs:248-250), so the eval runner and any direct run_agent caller ignore the
manifest budget. (PRD-004 R-A.3; audit Med.)
Change. In run_agent_inner, default the step budget to def.spec.max_steps
unless RunOptions explicitly overrides it.
Acceptance criteria. A test with a manifest max_steps: N and no RunOptions
override stops at N steps.
Files. crates/wovyr-agent/src/runtime.rs. Size. S. Depends on: none.
Implementation notes (2026-07-09). RunOptions.max_steps changed from usize
(default 8, indistinguishable from an explicit 8) to Option<usize> (None =
“defer to the manifest, then the built-in default”); with_max_steps sets Some(n).
run_agent_inner now resolves the budget as opts.max_steps.or(def.spec.max_steps) .unwrap_or(DEFAULT_MAX_STEPS) — precedence explicit override > manifest >
built-in — used for the loop bound, the “did not finish within N steps” error, and
the agent.run span field. Existing pre-resolving callers (wovyr-runtime, the
server’s agents.rs doing req.max_steps.or(def.spec.max_steps)) are unaffected —
they set Some(..) before the call, so the new default branch only ever fires for
the previously-broken direct/eval callers. Acceptance:
runtime::tests::manifest_max_steps_is_the_default_budget (manifest max_steps: 0,
no override → fails at 0 steps) + explicit_max_steps_overrides_the_manifest_budget
(manifest 0 but with_max_steps(4) → completes), and the server’s existing
agent_level_max_steps_is_a_default_not_a_floor/max_steps_override_is_honored
still pass. Field-type change rippled to tool_loop.rs (opts.max_steps = Some(3))
and the with_max_steps unit test (Some(0)).
WS-E — Sandbox Activation
Section titled “WS-E — Sandbox Activation”SBX-101 [P0] — Wire SandboxManager::detect() + SandboxPool into the run path — DONE (2026-07-09)
Section titled “SBX-101 [P0] — Wire SandboxManager::detect() + SandboxPool into the run path — DONE (2026-07-09)”Problem. ShellTool hardcodes SandboxManager::native_only()
(crates/wovyr-tools/src/builtin.rs:561), so ContainerSandbox, FirecrackerSandbox,
SandboxPool, and FairScheduler are referenced only in tests/defs — never in the
wovyr-agent/wovyr-server run path. A node with Docker/gVisor/Firecracker never uses
them, and untrusted/verified runs simply fail closed. (PRD-004 R-E.1; audit High.)
Change.
- The tool/agent run path consumes
SandboxManager::detect()(capability-probed) and acquires from a sharedSandboxPool, so verified/untrusted work runs on the strongest available backend for itsTrustClass. - Keep
native_only()as an explicit opt-in for trusted first-party/local runs.
Acceptance criteria.
- A capability-gated integration test (Docker present) asserts an untrusted run selects the container backend, not native, and executes.
- A first-party run still uses native; a node with no strong backend fails closed for untrusted work (unchanged).
Files. crates/wovyr-tools/src/builtin.rs, sandbox/*, pool.rs;
crates/wovyr-agent/wovyr-server run wiring. Size. L. Depends on: none.
Implementation notes (2026-07-09). ShellTool is now stateful — it holds a
SandboxManager and a container image (WOVYR_SANDBOX_IMAGE, default alpine:3.20):
ShellTool::native_only() (native-only caps; fail-closed for verified/untrusted — the
CLI/local/test default) and ShellTool::with_manager(detected). execute resolves
the backend from ctx.trust_class against the manager’s capabilities and dispatches:
Native → the existing host-shell path (powershell/cmd/sh); Container/Gvisor →
run_container, which runs sh -c <cwd-wrapped> inside a network-isolated
ContainerSandbox (a non-sh shell request is rejected there, since the container is
Linux). The registry gained with_shell_using(manager) (with_shell() stays
native-only for back-compat); wovyr-server’s AppState::from_env calls
SandboxManager::detect().await and uses it when WOVYR_ENABLE_SHELL_TOOL=1, so a
Docker/gVisor node actually runs verified/untrusted shell work in a container.
Acceptance: capability-gated sandbox_backends.rs::shell_tool_runs_a_verified_run_ in_a_container_not_native (a Verified run executes cat /etc/alpine-release — only
succeeds inside the alpine image) + shell_tool_first_party_run_stays_native_even_ when_containers_exist (first-party runs on the host, where that file is absent);
both skip cleanly with no Docker. Deterministic offline coverage:
builtin::tests::shell_with_container_capability_routes_verified_run_off_native
(a Container-capable manager routes a verified run to the container backend — not
fail-closed, not native), plus the existing native-only fail-closed tests. Scope
note: the SandboxPool/FairScheduler integration into the shell path was
deliberately deferred — a ContainerSandbox handle is stateless config (each
execute still does its own docker run), so pooling the handles yields no
warm-container reuse (the pool’s own module doc calls persistent warm sessions “a
separate concern that needs a session-capable backend”); the real gap this ticket
targets — strong backends never selected on the run path — is fully closed by the
selection/dispatch/detect wiring. Bounded-concurrency pooling is tracked for the
session-capable-backend work rather than added here as non-functional ceremony.
SBX-102 [P0] — Windows Job Object resource limits in the native sandbox — DONE (2026-07-09)
Section titled “SBX-102 [P0] — Windows Job Object resource limits in the native sandbox — DONE (2026-07-09)”Problem. setrlimit memory/CPU/PID enforcement is #[cfg(unix)]-only; the
not(unix) branch applies only a timeout + output cap
(crates/wovyr-tools/src/sandbox/native.rs:37-70,149-173). On this Windows host
shell runs with zero resource isolation. (PRD-004 R-E.2; audit High.)
Change. Add a Windows Job Object (JOBOBJECT_EXTENDED_LIMIT_INFORMATION:
ProcessMemoryLimit, ActiveProcessLimit, and a CPU-rate control) in the non-Unix
path, mirroring the Unix setrlimit caps.
Acceptance criteria. A Windows-gated test asserts a child exceeding the memory or process-count cap is terminated; caps match the Unix path’s semantics.
Files. crates/wovyr-tools/src/sandbox/native.rs. Size. M. Depends on: none.
Implementation notes (2026-07-09). A #[cfg(windows)] JobObject guard
(windows-sys 0.61, Win32_System_JobObjects) is created from ResourceLimits and
assigned to the child right after spawn in NativeSandbox::run — the non-Unix analog
of the Unix setrlimit pre_exec hook. It sets JOBOBJECT_EXTENDED_LIMIT_INFORMATION
with ProcessMemoryLimit (RLIMIT_AS analog), ActiveProcessLimit (the container
pids.max analog — Unix native has none), and PerJobUserTimeLimit (total user-CPU
time — the RLIMIT_CPU total-time analog; chosen over the ticket’s parenthetical
“CPU-rate control” because the acceptance criterion prioritizes matching the Unix
semantics, which are a total-time quota, not a rate), plus KILL_ON_JOB_CLOSE so a
survivor can’t outlive the run when the guard drops. The handle is unsafe impl Send
(a process-global kernel handle) so run’s future stays Send across the .await.
Acceptance (runs for real on this Windows host, not just gated):
native::tests::job_object_active_process_limit_blocks_child_spawns (max_pids = 1 →
an assigned cmd.exe’s attempted child spawn is blocked) and
job_object_memory_limit_fails_an_over_allocating_child (a ~1 GiB allocation under a
256 MiB ProcessMemoryLimit throws OutOfMemoryException and dies non-zero). The
memory test surfaced a real PowerShell subtlety — a ;-chained OOM is non-terminating
by default, so the assertion required $ErrorActionPreference='Stop' to make the cap
breach actually end the process. windows-sys 0.61 was already in the tree (ring links
it), so no new duplicate version / offline fetch and no cargo-deny change.
WS-G — Server Durability & Auth Lifecycle
Section titled “WS-G — Server Durability & Auth Lifecycle”SRV-101 [P0] — Graceful shutdown / drain — DONE (2026-07-09)
Section titled “SRV-101 [P0] — Graceful shutdown / drain — DONE (2026-07-09)”Problem. axum::serve is called without with_graceful_shutdown and no
SIGTERM/SIGINT handling (crates/wovyr-server/src/lib.rs:339-344); in-flight runs and
spawned tasks are killed abruptly, and the dispatch-loop abort only runs after
serve returns (which only happens on hard error). (PRD-004 R-G.1; audit High.)
Change. Wire a shutdown signal (SIGTERM/SIGINT) into axum::serve, drain
in-flight requests within a bounded deadline, and cleanly stop the dispatcher loops.
Acceptance criteria. A test sends the shutdown signal mid-request and asserts the in-flight request completes and new connections are refused; dispatch loops stop.
Files. crates/wovyr-server/src/lib.rs. Size. M. Depends on: none.
Implementation notes (2026-07-09). serve() extracted into serve_http/serve_tls
helpers, each taking a shutdown: impl Future. HTTP uses axum’s
.with_graceful_shutdown(shutdown); TLS uses axum_server::Handle::graceful_shutdown (Some(grace)) triggered from a task awaiting the same future. shutdown_signal()
resolves on SIGINT (any platform) or SIGTERM (Unix) via tokio::select!. A bounded
WOVYR_SHUTDOWN_GRACE_SECS (default 30) caps the drain; after the serving future
returns, the dispatch loops are aborted (previously that abort only ran on a hard
error, since nothing signaled a clean stop). Added tokio signal+macros features.
Acceptance: graceful_shutdown_drains_in_flight_then_refuses_new_connections
drives serve_http with a test-controlled shutdown future: a slow in-flight request
(gated by a Notify) completes with 200 after shutdown is triggered, then a new
connection is refused once the drained serving future returns.
SRV-102 [P1] — Durable async-run store (or documented non-durability) — DONE (2026-07-09)
Section titled “SRV-102 [P1] — Durable async-run store (or documented non-durability) — DONE (2026-07-09)”Problem. RunStore is in-memory only (crates/wovyr-server/src/state.rs:171-180);
the background tokio::spawn executing an async agent run
(agents.rs:144) has no checkpoint, so a restart loses every in-flight/pollable run
and clients poll a run that can never finish. (PRD-004 R-G.2; audit High.)
Change. Persist run records (status transitions) durably and, on startup, mark
orphaned Running async runs as Failed (a bare agent run has no checkpoint to
resume) — or, if durability is out of scope, document non-durability explicitly in
the API and return a clear terminal status.
Acceptance criteria. A restart-simulation test asserts a previously-Running
async run is reported terminally (not stuck Running) after reopen.
Files. crates/wovyr-server/src/{state.rs,agents.rs}. Size. M.
Depends on: none.
Implementation notes (2026-07-09). Chose durability + reconcile-on-startup (the
first ticket option). RunStore gained a path and new_with_path; RunRecord’s
inserted_at switched from a restart-meaningless Instant to wall-clock
inserted_at_ms so records round-trip through JSON (the same DUR-404 move
IdempotencyStore made). Every insert_running/finish persists via atomic_write.
On reopen, any run still Running is reconciled to terminal Failed (“server
restarted while the run was in flight; agent runs are not resumable”) and re-persisted,
so a poller gets a truthful terminal status rather than a stuck-Running poll or a
404. AppState::from_env opens it at ~/.wovyr/server/async_runs.json; path: None
stays in-memory (tests). Acceptance: run_store_tests::running_run_is_reconciled_ to_failed_after_restart (reopen against the same path shows the orphan Failed, a
finished run keeps its terminal status) + in_memory_store_persists_nothing.
SRV-103 [P1] — Durable webhook outbox + delivery worker — DONE (2026-07-09)
Section titled “SRV-103 [P1] — Durable webhook outbox + delivery worker — DONE (2026-07-09)”Problem. Webhook delivery + retries are in-process fire-and-forget
(crates/wovyr-server/src/webhooks.rs:138-152), retrying via tokio::sleep in a
spawned task (:116-119); a crash drops all pending retries and dead-letters are only
logged (:107-113), not persisted. (PRD-004 R-G.3; audit High.)
Change. Add a durable outbox (persisted delivery attempts + a DLQ) and a delivery worker that survives restart; dead-letters land in a queryable store, not just a log.
Acceptance criteria. A restart-simulation test asserts a pending delivery is retried after reopen; an exhausted delivery lands in the persisted DLQ.
Files. crates/wovyr-server/src/webhooks.rs; a durable outbox store.
Size. L. Depends on: none.
Implementation notes (2026-07-09). New webhook_outbox module: a durable
WebhookOutbox ({pending, dlq} document, atomic_write on every mutation, path: None = in-memory). dispatch now journals each delivery as pending before its task
runs (storing the subscription id, not its secret — the secret is re-resolved from
the webhook store at send time, so it’s never duplicated into the outbox even under the
encrypted store); spawn_delivery settles the entry — remove on success, dead_letter
on exhaustion. serve() calls webhooks::recover_outbox on startup to re-dispatch
deliveries pending from a dead process (re-resolving the sub by id; a deleted sub drops
the stale entry). A new tenant-scoped GET /api/v1/webhooks/dead-letters serves the
persisted DLQ (secrets never included). deliver and its retry/signing tests are
unchanged. Acceptance: webhook_outbox::tests::{pending_delivery_survives_reopen, dead_letter_is_persisted_and_queryable} (store round-trips across the reopen “restart”
stand-in) + webhooks::tests::dispatch_dead_letters_exhausted_delivery_into_the_outbox
(the real dispatch path dead-letters an always-failing delivery into the DLQ). The
existing dispatch tests reset to an in-memory outbox (with_in_memory_webhook_outbox)
so concurrent from_env tests don’t race the shared durable file.
SRV-104 [P1] — API-key lifecycle: expiry, rotation, revocation — DONE (2026-07-09)
Section titled “SRV-104 [P1] — API-key lifecycle: expiry, rotation, revocation — DONE (2026-07-09)”Problem. The API-key store is a bare hash → principal map; the only operation
is mint (crates/wovyr-server/src/auth.rs:238-241,275-277,301-312). No created_at,
TTL, revoke, rotate, or last-used. (PRD-004 R-G.4; audit High.)
Change. Add key metadata (created/expires/revoked/last-used), a revoke endpoint, and rotation; reject expired/revoked keys at auth time.
Acceptance criteria. Tests: an expired key is rejected; a revoked key is rejected; rotation issues a new key and invalidates the old on a grace schedule.
Files. crates/wovyr-server/src/auth.rs; CLI wovyr auth subcommands.
Size. M. Depends on: none.
Implementation notes (2026-07-09). The store value went from a bare principal
string to a KeyRecord { key_id, principal, created_at_ms, expires_at_ms, revoked, last_used_ms } (keyed by the key’s SHA-256 hash; key_id = key_<first 12 hex of the hash>, the non-secret handle for revoke/rotate). principal_for now enforces
revocation + expiry via a shared resolve_live_key, refreshing last_used at most
once/min/key to avoid rewriting the file on every request. FileApiKeyStore gained
create_key(principal, ttl), list_keys, revoke(key_id), and
rotate(key_id, grace) (mints a replacement, sets the old key to expire after the
grace window — both valid during it, only the old lapses after). load() transparently
migrates the pre-SRV-104 hash → principal on-disk format, so existing keys keep
authenticating. CLI: wovyr auth create-key [--ttl-days], list-keys, revoke <id>,
rotate <id> [--grace-hours]. Acceptance: auth::tests::{expired_key_is_rejected, revoked_key_is_rejected, rotation_issues_new_key_and_expires_old_after_grace, legacy_hash_to_principal_format_is_migrated}. Scope note: the revoke/rotate
surface is the CLI (operating on the shared ~/.wovyr/auth store, like kms/memory);
a server route for it wasn’t added — the CLI is the operator path, consistent with
how wovyr auth create-key already worked pre-ticket.
WS-H — Workflow Postgres Core
Section titled “WS-H — Workflow Postgres Core”WFL-101 [P0] — Postgres connection pool + reconnect — DONE (2026-07-10)
Section titled “WFL-101 [P0] — Postgres connection pool + reconnect — DONE (2026-07-10)”Problem. The Postgres store uses a single tokio_postgres::Client
(crates/wovyr-workflow/src/postgres.rs:87); every call serializes on one TCP
connection and there is no reconnect if the driver task dies (it only logs,
:102-106). (PRD-004 R-H.1; audit High.)
Change. Back the store with deadpool-postgres/bb8; add health-check +
reconnect.
Acceptance criteria. A capability-gated test asserts concurrent store calls don’t serialize on one connection and that a dropped connection recovers on the next call.
Files. crates/wovyr-workflow/src/postgres.rs. Size. M.
Depends on: none. Blocks: WFL-103, WFL-104.
Implementation notes (2026-07-10). Replaced the single tokio_postgres::Client
with a hand-rolled PgPool (semaphore-bounded, WOVYR_PG_POOL_MAX default 8) that
reuses idle clients and transparently reconnects — a client whose background
driver died (is_closed()) is discarded on return to the pool and a fresh one dialed
on the next checkout. Every store method now does self.pool.get().await? +
conn.client()..., so concurrent calls get distinct connections instead of serializing
on one socket. Hand-rolled rather than pulling deadpool/bb8: neither is vendored in
this offline workspace, and the needs are modest — the same “hand-roll to avoid a heavy
dep” call as the S3 signer / cron evaluator. Acceptance (validated live against a real
remote Aiven Postgres over TLS, not just capability-gated):
postgres_store::tests::concurrent_store_calls_are_served_by_the_pool (16 independent
executions’ appends+loads complete concurrently without deadlock/serialization failure)
passed against the live database. The reconnect path is structural (every get()
discards a closed client); forcing a mid-test backend kill needs pg_terminate_backend,
left to a dedicated drill.
WFL-102 [P0] — Sub-workflow recursion depth guard — DONE (2026-07-10)
Section titled “WFL-102 [P0] — Sub-workflow recursion depth guard — DONE (2026-07-10)”Problem. A workflow activity naming its own (or a mutually-recursive) workflow
recurses forever; run_subworkflow boxes the future but caps nothing
(crates/wovyr-workflow/src/engine.rs:998-1029). (PRD-004 R-H.2; audit High.)
Change. Thread a depth counter (or ancestor set) through sub-workflow resolution; fail closed past a configurable max depth / on a detected cycle.
Acceptance criteria. A test with a self-referential workflow fails with a clear depth/cycle error instead of hanging/overflowing.
Files. crates/wovyr-workflow/src/engine.rs. Size. S. Depends on: none.
Implementation notes (2026-07-10). Engine gained a max_subworkflow_depth
(with_max_subworkflow_depth, default DEFAULT_MAX_SUBWORKFLOW_DEPTH = 16).
run_subworkflow derives the nesting depth from the ::-delimited child id (each
level appends one ::<activity>), and if it exceeds the cap fails the activity closed
via terminal_activity_failure with a clear “sub-workflow nesting depth N exceeded”
message rather than recursing until the stack overflows. (Root execution ids are
::-free by construction, so the separator count is the true depth.) Acceptance:
temporal_gaps::self_referential_subworkflow_fails_with_a_depth_error (a workflow whose
workflow activity names itself, with_max_subworkflow_depth(3), fails with the depth
error and creates no execution past the cap — the test terminating proves no
hang/overflow).
WFL-103 [P1] — TLS to Postgres — DONE (2026-07-10, live-validated)
Section titled “WFL-103 [P1] — TLS to Postgres — DONE (2026-07-10, live-validated)”Problem. connect/run_migrations hardcode NoTls
(crates/wovyr-workflow/src/postgres.rs:98,121). (PRD-004 R-H.3; audit High.)
Change. Support MakeRustlsConnect; require TLS for non-loopback DB hosts
(refuse plaintext to a remote host).
Acceptance criteria. A test asserts a non-loopback URL without TLS config is refused; a TLS connection to a loopback test server succeeds.
Files. crates/wovyr-workflow/src/postgres.rs. Size. M.
Depends on: WFL-101.
Implementation notes (2026-07-10). resolve_tls_mode parses the connection string
and refuses plaintext to a non-loopback host (Error::Config) unless TLS is
requested (sslmode=require or WOVYR_PG_TLS=1) — loopback/Unix-socket hosts still
allow plaintext (trusted-local). The dial path branches NoTls vs a rustls
MakeRustlsConnect (tokio-postgres-rustls 0.13, rustls 0.23 ring provider passed
explicitly so no process-global default is needed). Certificate handling matches libpq
sslmode semantics: require encrypts without identity verification
(AcceptAnyServerCert — signatures still checked via the ring provider’s algorithms),
which is what lets a managed DB with a private project CA connect without its CA bundle;
WOVYR_PG_TLS_VERIFY=1 opts into full Mozilla-webpki-root verification for a public-CA
host. Acceptance: the refuse-plaintext guard is unit-tested offline
(postgres::tests::tls_guard_refuses_plaintext_to_remote_but_allows_loopback), and the
whole store was validated live end-to-end against a real remote managed Postgres
(Aiven, sslmode=require, TCP :10281) — wovyr admin migrate --target workflow
succeeded over TLS, and all six postgres_store integration tests passed against it
(so WFL-101’s pool and WFL-104’s concurrent-seq test are live-validated too, not just
capability-gated). The earlier offline blocker (only tokio-postgres-rustls 0.10 /
rustls 0.21 was vendored) was resolved by fetching 0.13 with net.offline=false. The
version-skew test opens a raw NoTls admin connection for its fake-row setup and now
skips cleanly on a TLS-only host (that behavior is orthogonal to transport).
WFL-104 [P1] — Fenced event-sequence generation — PARTIAL (seq-safety done; lease fencing deferred, 2026-07-10)
Section titled “WFL-104 [P1] — Fenced event-sequence generation — PARTIAL (seq-safety done; lease fencing deferred, 2026-07-10)”Problem. Postgres event append computes seq via SELECT MAX(seq)+1
(crates/wovyr-workflow/src/postgres.rs:152-166), safe only under “one driver per
execution”; a lease-expiry race (old worker still running while a new one resumes,
worker.rs:98-101) yields concurrent appends → PK violation. (PRD-004 R-H.4; audit
Med.)
Change. Use a DB identity/sequence with INSERT … RETURNING, and fence writes by
lease token (reject an append from a superseded lease).
Acceptance criteria. A capability-gated test simulating two overlapping workers on one execution asserts no PK violation and no forked history.
Files. crates/wovyr-workflow/src/{postgres.rs,worker.rs}. Size. M.
Depends on: WFL-101.
Implementation notes (2026-07-10). The PK-violation half is fixed: per-execution
seq allocation moved from the racy SELECT MAX(seq)+1 (two overlapping appenders both
read the same MAX → (execution_id, seq) PK collision) to an atomic
INSERT … ON CONFLICT DO UPDATE SET next_seq = next_seq + 1 RETURNING on a dedicated
workflow_event_seq counter row (new V2__event_seq_counter.sql migration, back-filled
from existing events). The UPDATE row-locks per execution, so concurrent appenders get
distinct, contiguous seqs and never collide. Acceptance (validated live against a real
remote Aiven Postgres, not just capability-gated):
postgres_store::tests::concurrent_appends_to_one_execution_get_distinct_contiguous_seqs
(24 concurrent appends to one execution yield exactly the distinct seqs 1..=24, no PK
violation) passed against the live database over TLS. Deferred — lease-token fencing
(the “no forked history”
half): rejecting a superseded worker’s appends needs a fence token threaded from
WorkQueue::lease → Worker → Engine → EventLog::append, a cross-crate signature
change through the EventLog port (and all its impls — in-memory/file/Postgres) that
can only be validated against the live overlapping-worker race on real Postgres. Left as
a follow-on rather than shipped blind; the counter table eliminates the concrete crash
the ticket’s evidence cites, and two overlapping workers now corrupt nothing at the PK
level (though they can still interleave events until fencing lands).
WS-I — Secret Default
Section titled “WS-I — Secret Default”SEC-101 [P1] — Default the secrets store to encrypted-at-rest — DONE (2026-07-10)
Section titled “SEC-101 [P1] — Default the secrets store to encrypted-at-rest — DONE (2026-07-10)”Problem. The default secrets store writes plaintext secrets.json
(crates/wovyr-secrets/src/store.rs:82-126); at-rest encryption
(EncryptedFileSecretStore via KMS) is opt-in behind WOVYR_SECRETS_ENCRYPT_AT_REST
(crates/wovyr-config/src/secrets.rs). (PRD-004 R-I.3; audit High.)
Change. Make encrypted-at-rest the default; plaintext becomes an explicit opt-out
(WOVYR_SECRETS_PLAINTEXT=1). Provide a documented migration for an existing
plaintext store (the two use distinct filenames, so a one-time re-seal step).
Acceptance criteria. A fresh vault writes ciphertext to disk by default; the plaintext opt-out still works; a migration test re-seals an existing plaintext file.
Files. crates/wovyr-config/src/secrets.rs, crates/wovyr-secrets/src/*; docs.
Size. M. Depends on: none.
Implementation notes (2026-07-10). wovyr_config::env::secrets_encrypt_at_rest()
now returns true by default; WOVYR_SECRETS_PLAINTEXT=1 is the explicit opt-out, and
the old opt-in var (WOVYR_SECRETS_ENCRYPT_AT_REST) is still honored and wins over a
contradictory plaintext opt-out (fail toward the safer mode). The migration is
automatic, not just documented: EncryptedFileSecretStore::migrate_plaintext()
(new, in wovyr-secrets) re-seals every legacy secrets.json record whose
(namespace, name) isn’t already sealed (existing encrypted records win), persists
once atomically (all-or-nothing — a KMS failure writes nothing and leaves the
plaintext file for a retry), then retires secrets.json →
secrets.json.migrated.bak with a loud warn! telling the operator to delete the
backup once verified. build_secrets_vault invokes it whenever it constructs the
encrypted store, so both the server and CLI migrate on first touch. Acceptance:
agreement.rs::build_secrets_vault_agrees_... (fresh vault writes secrets.enc.json
only, raw bytes never contain the value), plaintext_opt_out_still_writes_the_legacy_ store, default_flip_migrates_an_existing_plaintext_store (a plaintext-era secret
stays resolvable after the flip; the plaintext file is retired), plus
encrypted_store::tests::{migrates_a_legacy_plaintext_store_and_retires_the_file, migration_does_not_clobber_existing_sealed_records} (rotation window survives;
idempotent; sealed records never clobbered).
WS-J — Release & CI Reconciliation
Section titled “WS-J — Release & CI Reconciliation”DX-101 [P1] — Reconcile versioning + add a CHANGELOG — DONE (2026-07-10)
Section titled “DX-101 [P1] — Reconcile versioning + add a CHANGELOG — DONE (2026-07-10)”Problem. Cargo.toml version is 0.1.0 (Cargo.toml:26), README badge 0.1.0,
both SDKs 0.1.0 — while the repo is tagged v0.3.0; there is no root CHANGELOG.md.
The repository URL also diverges (Cargo.toml → wovyr-ai/wovyr; Python
pyproject.toml → punarduttrajput/wovyr). (PRD-004 R-J.1; audit High/Low.)
Change. Bump workspace/badge/SDK versions to match the tag; add a maintained root
CHANGELOG.md (Keep-a-Changelog); unify the repository URL across all manifests.
Acceptance criteria. Versions agree with the latest tag; CHANGELOG exists with the v0.1–v1.0 history; one canonical repo URL everywhere.
Files. Cargo.toml, README.md, sdks/*/; new CHANGELOG.md. Size. S.
Depends on: none.
Implementation notes (2026-07-10). Everything version-bearing now agrees on
0.3.0 (the latest tag): workspace Cargo.toml (which env!("CARGO_PKG_VERSION")
propagates to /healthz and the backup manifest automatically), the README badge +
“Current Version” note, both SDK manifests (package.json + lockfile,
pyproject.toml), the Helm Chart.yaml (version/appVersion), and
openapi.yaml’s info.version. Canonical repository URL unified to
https://github.com/punarduttrajput/wovyr (the actual remote — the old
wovyr-ai/wovyr in Cargo.toml was fictional); the TS SDK gained the repository
field it never had. Root CHANGELOG.md added (Keep-a-Changelog): 0.1.0/0.2.0/
0.3.0 entries dated from the roadmap docs’ own records, plus an [Unreleased]
section covering the v1.0 GA-hardening work and v1.1 Phase 1 to date.
DX-102 [P1] — Release automation: binaries + published image + SDK publish — DONE (authored; dry-run pending a tag, 2026-07-10)
Section titled “DX-102 [P1] — Release automation: binaries + published image + SDK publish — DONE (authored; dry-run pending a tag, 2026-07-10)”Problem. Only ci.yml exists; no release.yml, no cargo-dist/dist-workspace.toml,
no changelog generation. The TS SDK is unpublished (sdks/typescript/README.md), and
the container image is only ever built in CI (wovyr:ci), never pushed
(deployment/helm/wovyr/values.yaml:5-11). (PRD-004 R-J.2; audit High.)
Change. A tag-triggered release workflow producing signed cross-platform binaries,
a published container image (GHCR/Docker Hub), npm publish of @wovyr/sdk and
PyPI publish of wovyr-sdk, and a generated changelog. Default the Helm chart image
to the published repo.
Acceptance criteria. A dry-run (or tagged pre-release) produces binaries + a pushed image + packed SDK tarballs; the Helm chart references the published image.
Files. .github/workflows/release.yml, deployment/helm/wovyr/values.yaml,
SDK publish config. Size. L. Depends on: DX-101.
Implementation notes (2026-07-10). New .github/workflows/release.yml: on a
v* tag it builds wovyr binaries for Linux x86_64 / Windows x86_64 / macOS arm64
(+ sha256 sums), pushes the container image to GHCR
(ghcr.io/punarduttrajput/wovyr:{version,latest}, built with
FEATURES=tiered-memory,postgres — the compose/Helm feature set), packs both SDKs
(npm tarball, wheel+sdist) as release artifacts, and mints a GitHub Release whose
body is that version’s CHANGELOG.md section (awk-extracted). Registry publishes
are gated optional steps (skipped unless NPM_TOKEN/PYPI_API_TOKEN secrets
exist — the npm org’s 2FA constraint from the GA-005 work made unconditional
publish a guaranteed failure); the packed artifacts ship regardless.
workflow_dispatch is the built-in dry-run: identical build/pack jobs, zero pushes.
Helm values.yaml now defaults to the published GHCR image. Validation caveat:
GitHub Actions can’t execute in this environment — the workflow YAML is
parse-validated (serde_yaml) and hand-reviewed, but the acceptance dry-run needs a
real workflow_dispatch/pre-release tag on GitHub; run one before relying on it.
DX-103 [P1] — Add the dashboard and a Windows leg to CI — DONE (2026-07-10)
Section titled “DX-103 [P1] — Add the dashboard and a Windows leg to CI — DONE (2026-07-10)”Problem. .github/workflows/ci.yml has zero dashboard references despite the
Angular SPA + deployment/docker/dashboard.Dockerfile; every runs-on: is
ubuntu-latest despite recent Windows-specific breakage (the cmd.exe + temp-dir
fixes). (PRD-004 R-J.3; audit High.)
Change. Add an Angular CI job (npm ci && ng lint && ng build && ng test --watch=false) and a windows-latest matrix leg for cargo build/cargo test.
Acceptance criteria. CI builds+tests the dashboard and runs the Rust suite on Windows on every PR; both are required checks.
Files. .github/workflows/ci.yml. Size. M. Depends on: UI-102 (so the
dashboard test job has specs to run — may land together).
Implementation notes (2026-07-10, landed together with UI-101/102). Two new
ci.yml jobs: rust-windows (windows-latest, cargo build/cargo test
--workspace — which finally compiles the #[cfg(windows)] SBX-102 Job Object
path in CI) and dashboard (Node 20, npm ci + ng build + ng test --watch=false --browsers=ChromeHeadless over UI-102’s specs). Landing the Windows
leg surfaced two latent Windows test failures (pool.rs/scheduler.rs spawned a
bare echo, a shell builtin with no .exe) — fixed to spawn via cmd /C echo on
Windows. Evidence the Windows leg is green: the full cargo test --workspace
suite passes on this real Windows 11 host (71 suites, 0 failures), and the
dashboard job’s exact commands pass locally (build clean; 19 specs green in
headless Chrome). Marking both as required checks is a GitHub branch-protection
setting — flip it in repo settings once the first run is green; not expressible
in the workflow file itself.
WS-K — UI Security & Tests
Section titled “WS-K — UI Security & Tests”UI-101 [P1] — Move the bearer token off localStorage — DONE (2026-07-10, live-verified)
Section titled “UI-101 [P1] — Move the bearer token off localStorage — DONE (2026-07-10, live-verified)”Problem. The API key/JWT is held in localStorage
(dashboard/src/app/core/session.ts:22-73) → XSS-exfiltratable. (PRD-004 R-K.1; audit
High.)
Change. Store the credential in memory/sessionStorage at minimum, or (preferred)
issue an httpOnly cookie via a thin BFF. Keep tenant/principal (non-secret) where they
are.
Acceptance criteria. The bearer token is no longer readable from localStorage
via window.localStorage; auth still works across a page reload per the chosen model.
Files. dashboard/src/app/core/{session.ts,tenant.interceptor.ts}. Size. M.
Depends on: none.
Implementation notes (2026-07-10). The credential moved to sessionStorage
(wovyr.credential.v1): survives reloads within the tab (the chosen persistence
model), dies with it, and is never written to localStorage — which now holds only
the non-secret tenant/principal. A legacy pre-UI-101 blob that still embeds an
apiKey is migrated on first load: adopted into sessionStorage (unless a newer
credential is already there) and scrubbed from the persisted blob, so upgraders keep
no residual copy in the weaker store. (tenant.interceptor.ts needed no change — it
reads the Session signals, not storage.) The httpOnly-cookie BFF remains the
preferred end state but the dashboard talks directly to wovyr-server today (BFF
deferred, pre-existing decision). Verified three ways: unit specs
(session.spec.ts, incl. the literal acceptance assertion that the token appears
nowhere in window.localStorage); live in a real browser — this dev machine’s
own pre-UI-101 stored key was observed migrating (localStorage blob scrubbed to
tenant/principal only, key present in sessionStorage), a reload kept the session
signed in, and a fresh save through the actual Sign-in form landed the token in
sessionStorage only.
UI-102 [P1] — UI test coverage + enable specs — DONE (2026-07-10)
Section titled “UI-102 [P1] — UI test coverage + enable specs — DONE (2026-07-10)”Problem. Zero specs; skipTests:true is set on every schematic
(dashboard/angular.json:13-37); Karma/Jasmine are installed but unused. (PRD-004
R-K.2; audit High.)
Change. Remove the global skipTests; add service specs for the riskiest logic —
the SSE stream parser (agent.service.ts:159-245) and the manifest YAML round-trip
(agent.service.ts:67-150, workflow.service.ts:136-177) — plus one smoke e2e.
Acceptance criteria. ng test --watch=false runs a non-trivial suite green; the
SSE parser and manifest round-trip are covered.
Files. dashboard/angular.json, dashboard/src/app/core/*.spec.ts. Size. M.
Depends on: none. Pairs with: DX-103 (which runs these in CI).
Implementation notes (2026-07-10). The global skipTests: true schematic
defaults are gone from angular.json (only the component style setting remains).
Three spec files, 19 specs, all green in headless Chrome (ng test --watch=false --browsers=ChromeHeadless): core/session.spec.ts (UI-101’s storage
invariants + legacy migration + corrupt-blob fallback),
agent-studio/agent.service.spec.ts (the manifest round-trip — toManifest ∘ fromManifest as inverses incl. pinned-model/minimal/real-example shapes — and the
SSE parser driven through runStream against a mocked streaming fetch: event
ordering, frames split across arbitrary chunk boundaries, SSE comments/unknown
types ignored, HTTP errors and event: error frames surfaced), and
workflow-builder/workflow.service.spec.ts (the YAML serializer: per-type field
mapping, quote/backslash escaping, invalid-inputs dropped, blank-transition
skipping, defaults). The “one smoke e2e” was deliberately traded for the live
browser-driven verification recorded under UI-101 — no e2e framework
(Cypress/Playwright) is installed or vendored, and adding one offline wasn’t
possible; revisit if an e2e harness lands.
Exit criteria (Phase 1)
Section titled “Exit criteria (Phase 1)”- Every real provider reports accurate
cost_usd; a project’s daily-cost accumulator advances by real spend (PRV-101). - A long tool loop stays within a configured token budget; multi-tool turns run
concurrently; manifest
max_stepsis honored (AIC-101/102/103). - Untrusted runs use the strongest available sandbox; Windows enforces real resource limits (SBX-101/102).
- A server restart/shutdown loses no pollable run or pending webhook and drains in-flight requests; API keys expire/rotate/revoke (SRV-101..104).
- The Postgres workflow path pools connections, uses TLS, guards recursion, and can’t fork history under a lease race (WFL-101..104).
- Secrets are encrypted-at-rest by default (SEC-101).
- Versions/CHANGELOG/release automation/published image exist; the dashboard and
Windows are in CI; UI tokens are off
localStorageand the UI has tests (DX-101..103, UI-101/102).
Revision History
Section titled “Revision History”| Version | Date | Description |
|---|---|---|
| 1.0.0 | 2026-07-09 | Initial Phase-1 tickets from PRD-004 / the 2026-07-09 engineering audit (production-truth P0/P1 fixes) |