Continuous Hierarchical Analysis — Phased Implementation Plan
Status: Implementation plan, v1.0 (2026-07-06). Companion to the design doc Continuous Hierarchical Video Analysis & the Live Bar Raiser. This doc owns the build order, phase gates, and per-phase scope; the design doc owns the full rationale.
How to use: each phase section below is written to seed a spec-kit specify → plan → tasks cycle. Cadence is build → deploy → soak one week → observe → continue. The recruiter report is never bet on unproven code, so scoring graduates through shadow → primary.
Purpose and how to use this document
This is the phased implementation plan for continuous hierarchical video analysis. It is a companion to the design doc — "Continuous Hierarchical Video Analysis & the Live Bar Raiser" (Utkrushta/docs/continuous-hierarchical-analysis.md) — not a replacement for it. The design doc owns the why: the two-plane architecture, the window math, the Bar Raiser persona, the cost model, and the fourteen decisions (D1-D14) behind them. This document owns the how, in what order, behind which gates, building exactly what. Where the two disagree on sequencing or scope, this plan wins; for any mechanism detail, defer to the design doc.
What this document is for
Its real job is to feed spec-kit. Each phase section below is written as a pre-spec seed: it surfaces, in the shape our specs/NNN-* convention expects, everything a human needs to run speckit specify on that phase and get a usable spec.md — user stories with a P1/P2/P3 split, a concrete build list that seeds functional requirements, the seams touched in existing code, the data-model delta, the exit gate as measurable success criteria, and the explicit out-of-scope. From there the same person runs plan and tasks. So read a phase section not as narrative but as the raw material you paste into a specify prompt. The Spec-kit handoff section at the end says how many features each phase becomes and where the boundaries sit.
The cadence, and why
Every phase moves on one rhythm: build → deploy → soak a week → observe → continue. Ship the phase's code, put it in front of real (or replayed) traffic, then leave it running for about a week while you watch the dashboards named in each phase's soak criteria before starting the next phase. This is deliberate. The mechanisms here — leases, fencing epochs, CAS digest folds, a live scoring plane racing a batch fallback — are new to the team as well as to the codebase, and people need to get used to operating them before more weight is stacked on top. Just as important:
Decision. The recruiter report (
task_sessions.result_analysis) is the crown jewel and is never bet on unproven code. Moving scoring onto the new service graduates through shadow → primary over two phases, not a deploy-day flip. The service writes only to a shadow table until a full soak week proves byte-shape parity against the batch pipeline; only then does it earn the real column, with Fargate demoted to a fallback that is now trustworthy because it was never removed.
One consequence worth stating up front: the win in Phases 0-2 is latency, not accuracy. Those phases run today's v2 scoring pipeline live, so scores are equivalent by construction — the report simply lands at T+4-9 min instead of T+30-45. Any accuracy change and every candidate-facing feature arrives only with the awareness plane in Phase 3.
How to read this document
Read the sections in order; each assumes the ones before it.
| Section | What it gives you |
|---|---|
| Phasing overview | The four-phase map, the scoring/awareness plane split, and how report risk climbs from none to contained. |
| Shared mechanisms primer | Define-once reference for CAS, lease + fencing, one-pipeline-two-runners, the three stamps, and the digest — phases cite this rather than re-explain. |
| Prerequisite PRs | The five standalone workstreams (P1-P5) that land in parallel and gate specific phases. |
| Phase 0 — Build & Replay | Service skeleton + scoring plane, proven offline against recorded prod sessions. |
| Phase 1 — Shadow | The service running live in prod, writing to a shadow table only; Fargate still owns the real report. |
| Phase 2 — Primary | Shadow parity proven → the service becomes the report producer; the T+9 latency win lands. |
| Phase 3 — Awareness plane | L1/L2/digest + Bar Raiser nudges + candidate chat, per-position opt-in — the only candidate-facing phase. |
| Data model across phases | Every new table and column, tagged with the phase that introduces it. |
| Cross-cutting: flags, observability, rollback | Feature flags, BetterStack ops names, kill switches, and fire-drill expectations spanning all phases. |
| Spec-kit handoff | How to slice each phase into spec-kit features, suggested next feature numbers, and rough sequencing for a team of one to two. |
1. Phasing overview
This plan ships the continuous analysis system in four broad phases, each on the same cadence: build → deploy → soak a week → observe → continue. The ordering is dictated by one rule that overrides latency, cost, and convenience: the recruiter report (task_sessions.result_analysis) is the crown jewel and is never bet on unproven code. So the work that moves scoring off the batch container graduates through shadow → primary, rather than flipping on the day the service deploys. The design rationale for each mechanism lives in the design doc; this section owns the "in what order, with what gates" story and is the entry point for the per-phase spec-seeds that follow.
Two planes, and why the phases split cleanly
The system has two independent analysis planes, and the phase boundaries fall exactly on the line between them.
| Plane | What it is | Newness | Report-bearing? | Phases |
|---|---|---|---|---|
| Scoring plane | Today's v2 pipeline — 20-min segment MAP + REDUCE + finalize_task_session — run live during the session instead of post-hoc. Reuses airflow/dags/video_analysis verbatim via build-time COPY (no fork; see the Shared mechanisms primer, "one pipeline, two runners"). Takes the L1 activity timeline as its MAP timeline input, replacing the CV pre-pass. |
Runner is new; the scoring logic is unchanged. | Yes — writes the byte-shape-identical v2 report. | 0, 1, 2 |
| Awareness plane | L1 (1-min) / L2 (5-min) / running digest + the 3-stage Bar Raiser nudge engine + candidate chat, delivered over Supabase Realtime. | Entirely new, service-owned, fail-soft, candidate-visible. | No — additive addendum only (per R-SHAPE, it never enters result_analysis). |
3 |
The two planes share almost no runtime state: the scoring plane can go fully live and become the primary report producer before a single nudge is ever generated. That is what lets the phases divide with no awkward half-states — Phases 0-2 harden and graduate the scoring plane against an existing batch baseline, and Phase 3 stacks the awareness plane on top of a report path that is already trusted and reversible.
The four phases
| # | Name | One-line goal | Report risk | Candidate-facing | Exit gate (one line) |
|---|---|---|---|---|---|
| Phase 0 | Build & Replay (offline) | Build the service skeleton + scoring plane; prove it against recorded prod sessions in dev. | None (offline, separate Gemini key/project) | No | EQ-1..EQ-5 green on a ≥50-session paired corpus (noise floor documented), fault-injection shows zero silent degradation, windows proven config-driven, replay reproducible within the noise floor. |
| Phase 1 | Shadow (prod, silent) | Run the service live in prod doing full scoring, write only to a shadow store; Fargate still owns the real report. | Zero (result_analysis untouched) |
No | ≥200 prod sessions over ≥3 weeks across strata, EQ-1..EQ-6 green (incl. last-100 re-check), drain-to-report P95 ≤12 min in prod, Gemini TPM/RPM measured, zero shadow-attributable incidents. |
| Phase 2 | Primary (report flip) | Shadow proved parity → the service becomes the primary report producer at T+4-9 min; Fargate demoted to standby/fallback. | Contained (fallback now trustworthy) | No | ≥4-6 weeks continuous-primary, fallback rate <5% (trending toward <2%), reversed-shadow EQ green, R-MP4 + R-PROCTOR hold on 100% of sessions, reconciler deployed and fire-drilled. |
| Phase 3 | Awareness plane | L1/L2/digest + Bar Raiser nudges + candidate chat, per-position opt-in. | n/a (additive, flag-gated; report shape unchanged) | Yes | Zero solution leakage across ≥300 human-reviewed simulated nudges, dogfood + design partners clean, recruiter "Bar Raiser" badge + comparability policy shipped. |
Two framings to state plainly
Decision. The Phase 1/2 win is latency, not accuracy. The scoring plane in Phases 0-2 runs the same v2 MAP and REDUCE over the same activity timeline as batch, so scores are equivalent by construction — Phase 1 is "the batch pipeline, live." The only thing that changes is when the report lands: T+4-9 min after submit instead of T+30-45. Any accuracy change (the finer L1-derived timeline, cross-window continuity via the carry chain + digest) arrives only with the awareness plane in Phase 3, and even then it never enters scoring prompts (R-AWARE-SCORING). Do not sell Phases 1-2 as a quality upgrade; sell them as the same report, sooner.
Decision. Shadow exists because of the Gemini-sentinel hazard. The batch container treats any non-placeholder
result_analysisentry as "already done" and no-ops — it skips chunk download, ffmpeg, and Gemini entirely (core.py:1626-1641). That is exactly what makes Fargate a free fallback: submit always triggers the DAG, and the sentinel makes the run a no-op when the live path already finalized, or a full batch run when it didn't. But it cuts both ways — a wrong or partial live write would defeat the fallback, silently promoting broken code to primary. So in Phase 1 the service must never writeresult_analysis; shadow output goes tocontinuous_shadow_runs+ S3 only (R-IDLE). Only after shadow proves parity against the batch baseline does Phase 2 let the service write the real column. The report is never the place we find out the code is wrong.
Fargate is never deleted
Across all four phases the batch Fargate pipeline stays registered and standby — demoted, never removed. This is fallback-by-construction: you get it by not removing the existing submit → DAG trigger (shared/utils/airflow_utils.py, the FF_FARGATE_TASK_ANALYSIS seam at shared/feature_flags.py:24-33). In Phase 2 the service even re-triggers the same DAG run (dag_run_id = client_submission_id) after a successful finalize, so the sentinel makes it a no-op hop that still runs the Airflow post-steps (cert, notification, recommended) exactly once — and if the service failed to write, that identical trigger call is the full batch fallback. A fallback that is never exercised does not exist, so it also gets a periodic fire drill. The design doc's "batch retirement" is therefore an operational demotion, not a code deletion, and sits outside this four-phase spine.
How this maps to spec-kit features
This doc is the pre-spec input for a specify → plan → tasks cycle per phase. The suggested slicing (confirm the feature numbers at build time):
| Spec-kit feature | Covers | Rough boundary |
|---|---|---|
| "continuous scoring service — shadow" | Phase 0 and Phase 1 | One feature: the service skeleton, scoring-plane runner, replay harness, and shadow write path share a data model and codebase — splitting them would fork one spec mid-build. |
| "continuous scoring — primary + finalize seam" | Phase 2 | The trigger resolver, finalize seam, sentinel-no-op hop, reconciler, and R-MP4/R-PROCTOR contracts. |
| "bar-raiser nudge engine" | Phase 3 (part 1) | Nudge 3-stage gate, digest/L2 read model, audit ledger, report addendum. |
| "candidate chat" | Phase 3 (part 2) | Chat tables, candidate-app API route, Realtime broadcast delivery, answer policy. |
The five prerequisite fixes (P1-P5) are small standalone specs or plain PRs — see the Prerequisite workstreams (P1-P5) section. They land in parallel and gate specific phases: P4 gates Phase 0, P2/P3 gate Phase 1, P5 gates Phase 2 playback, and P1 gates the Phase 3 awareness context. The shared concurrency primitives (CAS, lease + fencing, one-pipeline-two-runners, the three stamps) are defined once in the Shared mechanisms primer and referenced from every phase rather than re-derived.
Sequencing is a near-serial spine — each phase's exit gate is the next phase's entry gate — realistic for a team of one to two, with the prerequisite PRs and the recruiter-frontend addendum (Phase 3) as the parallelizable side work.
2. Shared mechanisms primer
Six mechanisms carry every phase of this plan. They are the words the phase sections use without re-explaining: a phase says "claim the lease, fence the write, fold the digest under CAS" and assumes you read this once. Each is small, each is boring on purpose (correctness is design principle #1), and each is defined here with its concrete SQL/pseudocode plus a one-line why here — where it shows up across the phases. The design doc carries the full rationale; this primer carries the operational shape a builder needs at speckit specify time.
2.1 CAS — compare-and-swap (optimistic, never locked)
CAS is a conditional UPDATE guarded by an expected version: you read a row's version, compute a new value, and write it back only if the version has not moved. No row lock is held across the expensive work (a Gemini call), so a slow model turn never blocks another writer. If the guard fails the statement updates 0 rows — the signal to re-read and re-apply.
The canonical use is the running-digest fold. Each 5-min L2 boundary rewrites the whole-session digest in place; two folds must never both "win" and must never double-apply the same L2:
UPDATE session_digests
SET version = version + 1,
digest = :folded_digest,
applied_l2 = applied_l2 || :l2_id
WHERE tasksession_id = :ts AND task_id = :tid
AND version = :expected -- the guard: nobody folded since we read
AND NOT applied_l2 @> :l2_id; -- never apply the same L2 twice
-- 0 rows updated ⇒ another fold advanced the digest; re-read, re-fold, retry
Because version == len(applied_l2) by construction (invariant I3 in the design doc's correctness section), the digest is a pure fold over its applied L2 list — fully recomputable, so a version mismatch on load is self-healing (rebuild from the L2 rows). CAS also backs the lease claim and heartbeat (below) and every window-status transition.
Why here. CAS is the plan's default write discipline — it appears in Phase 0/1 (digest, window, segment writes) and never leaves. It is what lets the analyzer be at-least-once and multi-instance without a broker or a lock manager.
2.2 Lease + fencing — one owner per session, zombie-proof
A session is owned by exactly one analyzer instance at a time via a time-bounded lease on the analyzer_sessions row: claimed_by, lease_expires_at, and a monotonic lease_epoch fencing token. Claiming (or stealing an expired lease) and heartbeating are both CAS:
-- CLAIM (or steal a lease whose owner died): atomic compare-and-set
UPDATE analyzer_sessions
SET claimed_by = :me,
lease_epoch = lease_epoch + 1, -- new epoch fences the old owner
lease_expires_at = now() + interval '30 seconds'
WHERE tasksession_id = :ts
AND (claimed_by IS NULL OR lease_expires_at < now())
RETURNING lease_epoch; -- 0 rows ⇒ a live owner already holds it
-- HEARTBEAT every 10 s while working
UPDATE analyzer_sessions
SET lease_expires_at = now() + interval '30 seconds'
WHERE claimed_by = :me AND lease_epoch = :epoch; -- 0 rows ⇒ lease was stolen: STOP all work now
Fencing closes the zombie-writer hole. A worker that stalls (GC pause, network partition) inside a 20-second Gemini call can lose its lease to a new owner mid-flight. Every downstream write therefore carries the epoch it claimed under:
UPDATE analysis_windows
SET status = 'DONE', result = :json
WHERE window_id = :w
AND status = 'ANALYZING'
AND lease_epoch = :epoch; -- stale zombie epoch ⇒ 0 rows, the late write is rejected
The lease is session-scoped and all-or-nothing over that session's per-task rows: one owner drives L1, L2, digest, nudge, chat, and scoring for the whole (tasksession_id, *) fan. On restart the lease simply expires within its TTL (30 s) and any instance re-claims and resumes from the last persisted window — deploys are drain-less by construction.
Decision. Ownership is a fenced lease row, not a Postgres advisory lock. Through Supabase's pooled PostgREST/supavisor access there is no stable DB session to hold an advisory lock on, and a dead holder's lock lifetime is opaque; a lease row is inspectable, uses the same Supabase client every service already uses, and hands us an explicit fencing token (failure-modes §6).
Why here. The lease is introduced in Phase 0 (offline replay must survive worker restarts) and is the horizontal-scale primitive for Phase 3 — instances shard sessions by claim with no coordinator.
2.3 One pipeline, two runners
The scoring logic — the 18-turn v2 MAP unit, the v2 REDUCE, the calculator, the frozen report assembly — lives in exactly one directory, airflow/dags/video_analysis/. It is import-clean (no Airflow imports; the Fargate container already proves this). The batch Fargate image COPYs it at build time (task_analysis_runner/Dockerfile), and the new session-analyzer image COPYs the identical directory the same way. Zero forks. Only the runner differs — batch-at-submit versus live-during-session — never the logic.
video_analysis/ directory, two build-time COPYs, two runners. The CI path filter watches airflow/dags/video_analysis/** for both images so they never drift.The primary-vs-standby framing runs through the whole plan. Phases 0-2 make the live path the primary scoring runner; Fargate becomes the standby that the finalize seam still triggers on every submit. When the live path already finalized, Fargate's Gemini sentinel treats the existing result_analysis entry as "already done" and no-ops (core.py:1626-1641); when it didn't, the identical trigger call is the full batch fallback.
Decision. Because both images COPY the same directory, the CI path filter for each (
.github/workflows/task-analysis-image.yamland its newsession-analyzertwin) must both includeairflow/dags/video_analysis/**. If the filters drift, one runner ships an old pipeline — the single most dangerous maintainability failure this plan can create.Why here. This is what makes the "the batch pipeline, live" claim true in Phases 1-2: identical code, different runner, so scores are equivalent by construction and the win is latency, not accuracy.
2.4 The three stamps
Three small integers do all the coordination. Learn what each is and you can read every write in the system.
| Stamp | Lives on | Kind | Job |
|---|---|---|---|
window_index |
analysis_windows |
derived — a pure function of per-task footage arithmetic | Idempotent window identity. Any worker computes the same value from the chunk naming alone; UNIQUE(tasksession_id, task_id, level, session_prefix, window_index) + ON CONFLICT DO NOTHING means "create a window" is safe to attempt from anywhere. |
session_digests.version |
session_digests |
CAS-advanced, monotonic | The resume pointer. version == len(applied_l2) always, so on restart the digest is rebuilt from exactly the L2s already folded; it is also the double-apply guard (§2.1). |
lease_epoch |
analyzer_sessions |
fencing counter, bumped on each claim | One fenced writer per session. Every analysis write carries it; a stale epoch lands 0 rows (§2.2). |
The distinction that matters: window_index is derived and never stored as a counter. There is no incrementing sequence a crash could corrupt — it is recomputed from durable inputs (S3 keys + analyzer_sessions.config) every time.
Warning. Window sizes are snapshotted into
analyzer_sessions.configat session start and frozen for the session's life. Retuningl1_window_s/l2_window_smid-session would re-shard existing windows and breakwindow_indexidempotency (invariant I1); new sizes apply to new sessions only (failure-modes §4.7).
2.5 Digest vs L2 — running accumulation vs immutable window
These two are easy to conflate and must not be.
| L2 window | Digest | |
|---|---|---|
| Table | analysis_windows (level=2) |
session_digests |
| Cardinality | one row per 5-min slice, per task | one row per (tasksession_id, task_id) |
| Mutability | immutable — written once, never rewritten | capped-rewrite — rewritten in place each L2 boundary under CAS |
| Nature | an append-only fact (what happened in minutes 5-10) | a running accumulation of the whole session so far |
| Bound | naturally bounded (5 min of L1s) | token-capped fold, so it never grows unbounded over a 2-hour session |
They are produced together: per the canonical cost accounting, one combined Flash call at each 5-min boundary emits both the L2 window synthesis and the digest rewrite (R-COST fold-in; a standalone fold is a tuning variant, not the default). The digest is what the Bar Raiser reads for whole-session context; the L2s are what it reads for the recent 15 minutes.
Why here. The digest is the awareness plane's memory and the reason cross-window continuity exists at all (the batch pipeline has none below the REDUCE). It is introduced in Phase 3; L2 rows in
analysis_windowsarrive with the awareness plane too, alongside the scoring-plane segment rows already present from Phase 0.
2.6 Identity is per (tasksession_id, task_id)
Everything the analyzer produces is keyed by both the session and the task: windows, segments, the digest, nudges, and chat all carry task_id. This is not cosmetic. A session holds multiple tasks; the screenshare chunk counter is page-global across tasks (a static per-page-load counter, chunkStorage.ts:303-306), and a candidate can switch tasks mid-session. forceCut() guarantees no single chunk straddles two tasks (useShareScreenTusRecorder.ts:768-821), so question_key scoping is trustworthy — but window and segment indexes are computed over each task's own footage sequence, never over global wall-clock or global chunk numbers (R-MULTITASK). A scoring segment closes when 1200 s of that task's footage has accumulated; a task switch closes the open window early with a gap annotation.
The nudge engine and chat operate on the active task — the task of the most recent closed window — reading that task's digest and last-3 L2s. Chat messages carry task_id NOT NULL. This per-task identity is also what makes the report converge correctly: result_analysis entries, the finalize_task_session distinct-task_id count, and re-attempt semantics are all per-task today (migration 20260526170000), and the live path preserves that contract exactly.
Why here. Per-task identity is assumed silently by every phase's data-model delta. Any arithmetic in a phase spec that reasons about "the session's minute N" is wrong unless it means "this task's footage minute N."
3. Prerequisite PRs (P1-P5)
Five small, independent workstreams land in parallel with — and mostly before — Phase 0. Each is one of the D14 prerequisite fixes from the design doc, promoted here to a numbered, schedulable unit so the phase sections can name their dependencies precisely. None of them requires the session-analyzer service to exist yet, so all five can be handed to a spec-kit or plain-PR flow immediately. Every other section refers to these as "the prerequisite workstreams P1-P5." Sequencing: land them in parallel and close them before Phase 0 sign-off; P3 is the long pole (procurement + quota lead time) and the one true blocker.
Warning. P3 is the single hard blocker among the five. Phase 1 shadow roughly doubles Gemini request load on a quota nobody has measured, using a key shared with the proctor pipeline — a shadow 429-storm could starve the real report pipeline and proctoring at the same time (I-8;
followup-6.md§1-2). No shadow session may run until the separate GCP project,GEMINI_API_KEY_CONTINUOUS, and a recorded quota table all exist. The other four gate cleanly but are not load-bearing on the same day.
3.1 The five workstreams at a glance
| PR | What it does | Why it must precede | Gates | Size |
|---|---|---|---|---|
| P1 | Resolve positions.competencies → competencies.scope/long_scope + weightage into awareness-plane prompt context (the {skills_to_be_checked} placeholder is unsubstituted today) |
The nudge/chat judgment needs per-position expectations; this new prompt payload must be built and tested before the nudge engine lands | Awareness plane (Phase 3) | S — spec-kit feature |
| P2 | Fix GEMINI_FLASH_PRICING for gemini-3-flash-preview; price generate_tags with the Flash table; fold all Gemini tokens into cost_summary |
Shadow cost dashboards and the $2.50/session cap are meaningless while spend under-reports ~40% input / ~17% output | Trustworthy shadow cost metrics (Phase 1) | S — plain PR |
| P3 | Read + record actual Gemini RPM/TPM/RPD from the AI Studio console; stand up a separate GCP project + GEMINI_API_KEY_CONTINUOUS[_DEV] |
Shadow doubles load on an unmeasured, proctor-shared quota; blast-radius isolation and a known ceiling are preconditions | Replay isolation (Phase 0) + shadow & pacing (Phase 1) — BLOCKING | S — ops + env PR |
| P4 | Private ACL on all traces/continuous/* artifacts; never the public-read chunk ACL |
Replay is the first writer of candidate-reasoning and chat artifacts; they must not be world-readable from day one | Phase 0 | XS — plain PR |
| P5 | Spike + implement stream-copy (-c copy) concat for the screenshare_video MP4 |
The sentinel-no-op hop skips the batch ffmpeg, so the continuous service must produce the playback MP4 itself, cheaply | Phase 2 playback (R-MP4) | S — spike then PR |
3.2 Per-PR detail and spec-kit shape
P1 — Skill-scope wiring (awareness context only). A position's competencies carry scope/long_scope text plus a weightage, but the {skills_to_be_checked} template variable is never substituted anywhere in the current prompt set — position skill scopes reach no analysis prompt today (design doc, current-system correction 2). P1 builds a resolver that reads those scopes off the position and threads them into the awareness plane's context builder (the nudge judgment and chat prompts), where per-position expectations sharpen the Bar Raiser's questions. This is a small self-contained spec-kit feature: resolver + a context-payload field + tests.
Decision. P1 injects skill scopes into awareness-plane prompts ONLY (nudge + chat). They must never enter any scoring MAP or REDUCE prompt — the scoring plane stays byte-identical to batch so the score-parity claim holds (R-AWARE-SCORING; see the design doc's scoring-parity invariant). A P1 change that touches a scoring prompt is wrong by construction.
P2 — Pricing + cost_summary fix. GEMINI_FLASH_PRICING (airflow/dags/video_analysis/config.py:16-42) still holds gemini-2.5-flash-era rates for the preview model; cost_summary excludes the CV-timeline-class Gemini calls entirely; and generate_tags is priced against the Pro table (core.py:2584). Net effect: every prod cost figure — including the $1.10 avg — under-reports real Flash spend by ~40% on input and ~17% on output. P2 corrects the pricing table to verified gemini-3-flash-preview (or GA-successor) rates, prices generate_tags with the Flash table it actually uses, and folds all Gemini tokens into cost_summary. Plain PR. It gates Phase 1 because the per-session $2.50 budget cap and its 80% alert are only as trustworthy as the dashboard behind them.
P3 — Gemini quota read + separate GCP project/key. Two deliverables. (1) Read the current per-model RPM/TPM/RPD off the AI Studio console and record it in the repo — the number is on record nowhere, and the only empirical datum is zero 429s at concurrency 1 (I-8). This is the first hard input to the 350-concurrent procurement targets (Flash ≥1,000 RPM / ≥12M TPM; Pro ≥150 RPM / ≥3M TPM). (2) Stand up a separate Google Cloud project and issue GEMINI_API_KEY_CONTINUOUS (prod) plus GEMINI_API_KEY_CONTINUOUS_DEV (Phase-0 replay); quotas are per-project-per-model, and prod task-analysis and the proctor pipeline share one key today. The _DEV half unblocks Phase 0 replay isolation (migration §3.3); the prod half plus the recorded quota is the Phase 1 blocker. Mostly ops/procurement with a small env-wiring PR.
P4 — Private ACL on continuous artifacts. All trace, window, digest, nudge, and chat artifacts written under tasksessions/{id}/traces/continuous/ must be private by default — they contain model reasoning about a candidate and candidate chat. Do NOT repeat the existing public-read + 1-year-cache ACL used for chunks and combined_screenshare (a flagged concern in the design doc's ingest research). P4 is a tiny PR: the ACL parameter on the continuous S3 writer plus a test asserting no public-read is ever set. It gates Phase 0 because the replay harness is the first thing that writes these artifacts, and getting the ACL right from the first write avoids a public-object cleanup later.
P5 — Stream-copy MP4 spike. The recruiter report plays screenshare_video, the combined MP4 built today by a libx264 re-encode inside the batch container (~25% of container wall-clock). The chunks are same-codec, independently-playable H.264-in-WebM, so they should concat with ffmpeg -c copy (stream-copy) — the single-session path already uses a copy2 variant (core.py:1956-1961). P5 is a ~30-minute spike to confirm stream-copy yields an acceptable playback MP4, followed by the PR that makes the continuous service build it incrementally during the session. It gates Phase 2 playback via the R-MP4 contract: because the sentinel-no-op hop skips the batch ffmpeg step, no report flip happens until the MP4 exists on 100% of shadow sessions.
4. Phase 0 — Build & Replay (offline)
Phase 0 is where the bulk of the new session_analyzer service gets built and debugged where being wrong is free: in dev, against recorded production sessions, with no candidate and no recruiter in the loop. Nothing in this phase touches a prod row, a prod Gemini key, or the recruiter report. It ends when the service can re-run production sessions offline and reproduce their stored batch scores within the measured noise floor — the proof that lets Phase 1 point it at live prod traffic.
Decision. Phase 0 exercises the scoring plane only — 20-minute segment MAP plus REDUCE plus finalize-as-a-library, run over the existing CV activity-timeline pre-pass exactly as batch runs it (see "The scoring plane and the T+10 report" in the design doc). The L1/L2/digest awareness plane, the nudge/chat corpus, and the EQ-4 timeline-recall gate are validated by a separate awareness-plane replay in Phase 3, when L1 replaces the CV pre-pass. This keeps Phase 0's equivalence claim clean: continuous-vs-batch score deltas should collapse toward the temperature-0.3 replicate floor, because nothing algorithmic changes — only where and when the identical MAP/REDUCE code runs.
(a) Goal & the value it ships
Ship a deployable-to-dev session_analyzer container that ingests an ordered chunk stream, assembles 20-minute scoring segments by stream-copy concat, runs the unchanged v2 MAP unit per segment during the "session," and at drain runs the tail MAP + v2 REDUCE + combine_v2_buckets to produce a report object byte-shape-identical to today's result_analysis. The value is a de-risked scoring engine and a permanent regression harness: a replay loop that feeds historical prod S3 chunk streams through the pipeline at accelerated speed and scores the output against stored batch v2 results, so every later phase (and every future prompt or config change) has an offline oracle before it costs a real candidate anything.
(b) Scope — in / explicitly out
| In scope (Phase 0) | Explicitly out (owner) |
|---|---|
Service skeleton: config.py, main.py asyncio lifespan, db/ DAOs + Pydantic models, health/status HTTP surface |
TUS hook fan-out from fastapi_service/routers/tus.py → Phase 1 — Shadow |
Lease/fencing + state machine on analyzer_sessions |
Writing to continuous_shadow_runs on live prod sessions → Phase 1 — Shadow |
Ingest: /v1/chunk-events receiver, 60s S3 LIST sweeper, 20-min segment assembler |
Writing real task_sessions.result_analysis, finalize_task_session RPC, sentinel DAG hop → Phase 2 — Primary |
Scoring plane: run_v2_map_unit per 1200s segment over the CV pre-pass timeline |
L1/L2/digest workers, analysis_windows L1/L2 rows → Phase 3 — Awareness plane |
Finalize-as-library: tail MAP + run_v2_reduce + combine_v2_buckets → report object (harness output) |
Bar Raiser nudge engine, candidate chat, EQ-4 timeline recall, nudge-quality corpus → Phase 3 — Awareness plane |
| Replay harness: corpus builder, replayer, fault injector, EQ comparator | Recruiter-playback master MP4, screenshare_video population → Phase 2 — Primary |
Dev CI/CD image + video_analysis build-time COPY |
Prod deploy, prod Gemini key, capacity ramp → Phase 1/Phase 2 |
(c) Suggested user stories (P1/P2/P3)
| Pri | Story |
|---|---|
| P1 | As a platform engineer, I want the service to replay a recorded prod session's chunk stream in dev and emit a v2-shaped report object, so that I can compare it to the stored batch result without touching prod. |
| P1 | As a quality owner, I want the comparator to compute EQ-1/EQ-2/EQ-3/EQ-5/EQ-6 per session plus corpus aggregates against a measured noise floor, so that I can decide objectively whether the live scoring plane is equivalent to batch. |
| P1 | As a reliability engineer, I want a fault-injection suite (dropped chunks, prefix switch, out-of-order, 429 storm, mid-session restart) so that degraded runs degrade reported coverage and never silently emit a full-confidence report. |
| P2 | As an operator, I want every stage to log latency_ms + token_usage under continuous_* operations to Better Stack, so that I can tune segment/grace knobs from measured numbers. |
| P2 | As a platform engineer, I want window/segment identity derived purely from (session_ts, chunk_num) so that accelerated replay (no 10s inter-chunk wait) produces the same result as real time. |
| P3 | As a release manager, I want replay to be repeatable — same corpus + config + prompt version reproduces metrics within the noise floor — so the harness doubles as a permanent regression suite. |
(d) New things to build
| Item | Kind | Responsibility | Reuses vs new |
|---|---|---|---|
session_analyzer/config.py |
module | Env-driven knobs (segment/grace/drain/lease); snapshot into analyzer_sessions.config at claim |
New (mirrors notifications/ config) |
session_analyzer/main.py |
module | FastAPI app + asyncio lifespan; start loops | New (notifications/main.py pattern) |
routers/health.py |
endpoint | GET /hello, GET /status (loop states, lease gauges, queue depth) |
New |
routers/hooks.py |
endpoint | POST /v1/chunk-events bearer-token receiver (fed by the replayer in Phase 0) |
New |
core/session_registry.py |
module | analyzer_sessions state machine + lease claim/heartbeat/fencing |
New (CAS/lease mechanism) |
core/sweeper.py |
module | 60s S3 LIST reconcile + stale-lease reclaim + watermark/grace timers | New |
core/segment_assembler.py |
module | Chunks → 1200s stream-copy (-c copy) scoring unit + one ffprobe validation |
New (concat mechanics per video_utils.ffmpeg_concat) |
core/segment_worker.py |
module | run_v2_map_unit per 1200s of per-task footage over the CV timeline slice |
Reuse video_analysis.pipeline |
core/finalizer.py |
module | Drain → tail MAP → run_v2_reduce → combine_v2_buckets → report object |
Reuse video_analysis.synthesis / result_combiner |
db/*.py |
module | Service-role client + DAOs + Pydantic models for analyzer_sessions, analysis_segments |
New (notifications/db pattern) |
logging_setup.py |
module | Better Stack Logtail structured fields (operation, task_session_id) |
New (runner.py:35-90 precedent) |
analyzer_sessions |
table | Per-(tsid, task_id) lease/fencing/state + config/context snapshot |
New |
analysis_segments |
table | Scoring-plane 20-min MAP units; idempotency PK | New |
session_analyzer.Dockerfile |
CI | COPYs airflow/dags/video_analysis verbatim (two-runners) |
New (task_analysis_runner/Dockerfile pattern) |
.github/workflows/session-analyzer-dev.yaml |
CI | Build → GHCR → Coolify dev; path filter watches airflow/dags/video_analysis/** |
New |
scripts/replay/corpus_builder.py |
module | Select ≥50 stratified sessions; verify chunk inventory; ensure a batch v2 baseline exists (re-run if absent) | New |
scripts/replay/replayer.py |
module | Prod S3 read-only → ordered synthetic chunk events, accelerated | New |
scripts/replay/fault_injector.py |
module | Drops / prefix switch / out-of-order / 429 storm / restart | New |
scripts/replay/comparator.py |
module | EQ-1/2/3/5/6 + noise floor; markdown + JSON report to dag_reports/ |
New |
(e) Interfaces & seams touched in existing code
Phase 0 touches existing code almost entirely through import seams, not edits — the whole point of the two-runners rule.
| Seam | Where | Phase 0 use |
|---|---|---|
run_v2_map_unit, _build_v2_system_instruction, floor helpers |
airflow/dags/video_analysis/pipeline.py:833-1103, 177-222 |
Imported verbatim by segment_worker |
run_v2_reduce, merge_prompt_traces_across_segments, merge_v2_auxiliary_results |
synthesis.py:932-1053, 1306-1377, 568-652 |
Imported by finalizer |
combine_v2_buckets (frozen Appendix-A) |
result_combiner.py:374-463 |
Produces the comparable report object |
CV activity-timeline pre-pass + get_timeline_slice |
pipeline.py:360-444, timeline/timeline_builder.py:274-329 |
The scoring timeline source in Phases 0-2 (unchanged) |
gemini_client, calculator_v2 + config_v2, config.SEGMENT_DURATION |
video_analysis/ |
Imported; deterministic math + retry ladders |
| TUS post-finish hook | fastapi_service/routers/tus.py:152-274 |
Identified, not edited — the fan-out lands in Phase 1; Phase 0 drives /v1/chunk-events via the replayer instead |
| Gemini sentinel | task_analysis_runner/core.py:1626-1641 |
Not edited — the reason Phase 0 can safely never write result_analysis |
Dev Supabase ctzweurujanstonppfyh + dev bucket; prod S3 read via scoped IAM role |
infra | All writes dev-only; prod reads read-only |
(f) Data-model delta (→ Key Entities)
Phase 0 introduces two service-role tables (full DDL, RLS-4, and FK actions in the "Data model and state" section of the design doc):
analyzer_sessions— one row per(tasksession_id, task_id); the state machine (IDLE|LIVE|DRAINING|FINALIZING|DONE|FAILED_FALLBACK), the lease (claimed_by,lease_expires_at, heartbeat,lease_epochfencing), the frozenconfigsnapshot, and thecontext(task blob summary).UNIQUE(tasksession_id, task_id).analysis_segments— the scoring-plane 20-min MAP units:map_result,token_usage,latency_ms,chunk_range, status; idempotencyPRIMARY KEY(tasksession_id, task_id, segment_index).
The analysis_windows L1/L2 rows and session_digests arrive with the awareness plane in Phase 3 — Awareness plane; continuous_shadow_runs arrives in Phase 1 — Shadow. In Phase 0 the comparator writes only to harness artifacts (S3 dag_reports/ + JSON), never to any prod or dev report column.
(g) Shared mechanisms in play
See the shared-mechanisms primer for full definitions; the ones live in Phase 0:
- Lease + fencing —
analyzer_sessionssingle-owner claim, heartbeat,lease_epoch; the restart fault-injection case proves reclaim-and-resume. - CAS — conditional claim of a segment (
WHERE status='PENDING' AND lease_epoch=…); the digest CAS fold is not yet in play (Phase 3). - Two runners —
session_analyzer.Dockerfilebuild-time COPY ofvideo_analysis, plus the CI path filter watchingairflow/dags/video_analysis/**for both images. Established here, relied on forever. - Stamps —
segment_indexderived from per-task footage arithmetic (idempotent) andlease_epoch(fencing counter) apply;session_digests.versiondoes not (Phase 3). - Per-
(tasksession_id, task_id)identity — segments close when 1200s of per-task footage has accumulated, not wall-clock and not global chunk-count; a task switch closes the open segment early.
(h) Exit gate — Measurable Success Criteria
These conditions are the "observe window" of repeated replay passes; all must hold on the ≥50-session corpus before advancing to Phase 1 — Shadow. Thresholds are the design doc's EQ gates (see the "Migration and rollout" section); the noise floor is measured first, 20 sessions × 2 batch runs at temperature 0.3, and governs the relative form.
| # | Condition | Threshold |
|---|---|---|
| G-1 | EQ-1 total-score delta | median |Δ| ≤ 5 and ≤ 1.25× replicate median; P95 |Δ| ≤ 12 and ≤ 1.5× replicate P95; mean signed Δ within ±3 |
| G-2 | EQ-2 sub-dim agreement | within-1 ≥ 90%; exact match ≥ 55% (or ≥ replicate floor); NA-confusion ≤ 5%; within-session Spearman ρ median ≥ 0.80; no sub-dim mean signed delta > 0.75 |
| G-3 | EQ-3 recruiter-decision preservation | Kendall τ ≥ 0.85 per position (≥5 sessions); recommended-flip ≤ 5% and every flip human-reviewed against the video; cert-threshold flips same treatment |
| G-4 | EQ-5 grounding-citation validity | ≥ 98% evidence block_ids resolve; 100% derived timestamps sane; ≥ 90% human-verified citation accuracy (sample 200) |
| G-5 | EQ-6 operational envelope (offline form) | per-stage wall-clock consistent with T+4-9 basis (tail MAP 443-506s/segment, REDUCE 30-90s); coverage ≥ 90% average; cost/session ≈ batch ($1.10 avg, $3.25 max — scoring-only, no awareness spend); 0 sustained 429 |
| G-6 | Fault-injection suite | zero silent-degradation failures; coverage accounting accurate to ±1 window in every injected scenario |
| G-7 | Replay repeatability | same corpus + config + prompt version 2.6.0 reproduces every metric within the noise floor |
(i) What you watch during the soak
Phase 0 has no prod soak; the equivalent is repeated replay passes with the harness accumulating the corpus and the fault matrix. Watch:
- Better Stack
continuous_*operations —continuous_segment_map_done,continuous_tail_map,continuous_reduce,continuous_finalize(dev),continuous_sweep,continuous_lease_claim,continuous_lease_lost,continuous_coverage_gap,continuous_circuit_open; each carrieslatency_ms+token_usage+task_session_id. This is the surface from which segment/grace knobs are retuned. - Comparator report — markdown + JSON per run at
s3://…/dag_reports/continuous_replay/{run}.md: EQ aggregates, the EQ-3 flip list, the EQ-5 citation sample, and per-session drill-down. This is the primary artifact reviewers read. - Dev cost/latency dashboards — per-stage
latency_msdistributions and token/cost per session against the batch $1.10 average; the Flash-pricing correction from the Prerequisite workstreams (P2) so the cost line is not under-reported ~40%. - Fault-run reports — one per injected scenario, asserting coverage accounting to ±1 window and no full-confidence output on a degraded run.
(j) Failure modes handled + kill switches
The fault injector is where the correctness machinery from "Correctness: failure modes and invariants" gets its first fire-drill — offline, before any of it protects a real session:
| Injected fault | Design mode | Expected behavior |
|---|---|---|
| Dropped / never-arriving chunk | F1.2 | analyze-with-gaps; gap manifest recorded; coverage math honest |
| Out-of-order arrival | F1.3 | assembly by chunk_num, never arrival order — no effect |
| Mid-session prefix switch | F1.7 | per-prefix window namespace; old prefix closes on its own grace |
| 429 storm | F2.6 / K-7 | per-session circuit breaker trips → BATCH_ONLY; deferred segments; Discord alert (dev) |
| Coverage < 90% | K-5 | offline analogue of HANDOFF — session marked degraded, not finalized on the fast path |
| Mid-session restart | F4.1 / F4.2 | lease expires, re-claim, resume from last persisted segment; fencing rejects the zombie's stale write |
No kill switch is live in prod in Phase 0 (offline), but the circuit breaker (K-7), the coverage-fallback (K-5), and lease-fencing are all exercised deliberately here so they are tested code, not first-run code, when Phase 1 puts them in prod.
(k) Dependencies
| Dep | Kind | Why it gates Phase 0 |
|---|---|---|
P3 (separate GCP project + GEMINI_API_KEY_CONTINUOUS_DEV, measured RPM/TPM) |
Prerequisite PR | Blocking — replay must not starve the shared batch/proctor key; also yields the first continuous RPM/TPM profile |
| P4 (private ACL on trace/window artifacts) | Prerequisite PR | Gates Phase 0 — harness traces/artifacts must not inherit the public-read chunk ACL |
P2 (fix GEMINI_FLASH_PRICING + include timeline/tags tokens) |
Prerequisite PR | Soft — needed for trustworthy Phase 0 cost numbers (G-5) |
| P5 (stream-copy concat spike) | Prerequisite PR | Soft — informs the segment assembler's -c copy viability (the playback-MP4 payoff is Phase 2) |
| S3 chunk-lifecycle verification | Ops check | Blocking — the replay corpus depends on historical chunks not being lifecycle-deleted; pin to a replay-corpus/ prefix if any rule exists |
| Prior phases | — | None; Phase 0 is first |
P1 (position skill scopes) is an awareness-plane prerequisite and gates Phase 3 — Awareness plane, not Phase 0.
(l) Assumptions
- Historical prod chunk streams still exist at deterministic keys in S3; the corpus builder verifies inventory per session and re-runs batch v2 offline to create any missing baseline (26 v2 sessions have full
pipeline_metadatatoday; the rest are re-derived to reach ≥50). - The corpus is stratified per the design doc's sampling rule: length (<20 min single-pass vs >40 min segmented; corpus median analyzed footage ≈ 22 min), mechanism (GITHUB vs ZIP), and ≥8 distinct positions.
- Prompt version 2.6.0 and
calculator_v2are pinned identical across both pipelines for the entire comparison window; any bump resets the comparison clock. video_analysisis import-clean (no Airflow imports) — the Fargate container already proves this.- Accelerated replay is valid because all window/segment boundaries derive from
(session_ts, chunk_num), not wall-clock; dropping the 10s inter-chunk wait cannot change results. - Gemini runs at temperature 0.3, so batch is non-deterministic run-to-run; the noise floor is measured before any continuous-vs-batch delta is judged.
(m) Spec-kit slicing note
Phase 0 is best run as one spec-kit feature — "continuous scoring service — offline build & replay" — since the replay harness is not a side project but the acceptance vehicle for the service, and belongs in the same spec.md (its criteria are the exit gate above). Within that one spec, the Functional Requirements cleave naturally into three groups a plan.md can order near-serially: (1) service skeleton + ingest + lease/state; (2) scoring-plane library reuse + segment assembler + finalizer; (3) replay harness + fault injector + comparator. Group 1 unblocks 2; 2 unblocks 3; the corpus builder (a quickstart.md-style script) can proceed in parallel from day one. The Key Entities are analyzer_sessions + analysis_segments; Measurable Success Criteria are G-1…G-7.
Per the phasing spine's suggestion, a team may instead fold Phase 0 and Phase 1 — Shadow into a single "continuous scoring service — shadow" feature, with Phase 0 supplying the build/replay user stories and Phase 1 the prod-shadow stories; the spec boundary then sits at the point where output stops going to harness artifacts and starts going to continuous_shadow_runs on live traffic. Either way this is one feature (not two); take the next free specs/NNN- number at build time.
5. Phase 1 — Shadow (prod, silent)
Phase 1 takes the service built and replay-proven in Phase 0 — Build & Replay (offline) and runs it live in production, against real candidate sessions, doing full scoring end to end — but every output lands in a shadow store (continuous_shadow_runs + private S3), never in task_sessions.result_analysis. Batch (Fargate) stays the sole author of every recruiter-facing artifact; the submit → DAG seam is byte-for-byte unchanged. This is the purest soak in the whole plan: real sessions, real 429s, real service restarts, real late chunks, real multi-task switches, watched for a week or more with zero report risk. It is where the reliability machinery — lease/fencing, 429 pacing, drain, per-task segment identity — earns its trust in prod before Phase 2 — Primary (report flip) bets the crown jewel on it. The win Phase 1 unlocks is confidence, not latency; nobody outside the team sees anything change. See the design doc's "Migration & validation" section for the full rollout rationale.
Warning. The service must never write
task_sessions.result_analysisin Phase 1. A non-placeholder entry trips the Gemini sentinel (task_analysis_runner/core.py:1626-1641): the real batch run would skip download, ffmpeg, and Gemini and return the shadow entry verbatim — the unproven shadow pipeline silently becomes primary, and the finalize RPC's distinct-task_idcount could flipANALYSIS_DONEbefore batch ran. Enforced by a canary assertion (design risk R-1): the service refuses to construct anupsert_task_analysis_entry/finalize_task_sessioncall while in shadow mode. Shadow writes go tocontinuous_shadow_runs+ S3 only.
result_analysis write is forbidden.5a. Goal and the value it ships
Prove, under production conditions, that the continuous scoring plane produces scores equivalent to batch and that its reliability machinery survives real prod turbulence — while shipping zero candidate- or recruiter-visible change. The deliverable is a parity dataset (≥200 real sessions scored twice, once by batch and once by shadow) plus a clean operational record (lease reclaims, 429 handling, restarts, drains) that together constitute the entry evidence for the Phase 2 report flip. Value is de-risking: Phase 2 flips a flag onto a path that has already run silently beside batch for a week-plus without a single incident.
5b. Scope — in / explicitly out
| In scope (Phase 1) | Out of scope (owned by) |
|---|---|
| TUS post-finish hook fan-out (fire-and-forget hint to analyzer) | Submit → service /finalize seam; report flip → Phase 2 |
continuous_shadow_runs table + shadow writer + private-S3 report dump |
Writing result_analysis, pipeline_metadata, score → Phase 2 |
| Live scoring plane in prod: 20-min segment MAP + tail MAP + v2 REDUCE | Fargate demotion to standby / sentinel-no-op hop → Phase 2 |
| Client-side token-bucket rate limiter + 429 circuit breaker | Coverage-gated auto-fallback to batch (K-5 finalize eligibility) → Phase 2 |
Isolated Gemini key/project wiring (GEMINI_API_KEY_CONTINUOUS) |
L1/L2/digest awareness plane, nudges, chat → Phase 3 — Awareness plane |
| Deterministic shadow selection + ramp dial (allowlist → 5% → 25% → 100%) | Position skill/proficiency scopes in prompts (P1) — awareness-only, Phase 3 |
Nightly continuous_shadow_report EQ dashboards + continuous_* logs |
Recruiter-frontend Bar Raiser transcript panel → Phase 3 |
Phase 1 is entirely scoring-plane: it uses the same CV activity-timeline pre-pass as batch (not L1 windows), so scores are equivalent by construction. The finer L1-derived timeline arrives only with the awareness plane in Phase 3. Phase 1 is "the batch pipeline, live."
5c. Suggested user stories (P1/P2/P3)
- (P1) As the analysis-platform owner, I want the continuous service to score real prod sessions live and write only to a shadow store, so that I can measure parity against batch with zero risk to the recruiter report.
- (P1) As an SRE, I want the service on an isolated Gemini key/project fronted by a client-side rate limiter sized to the P3-measured quota, so that shadow traffic can never trigger 429s that starve the batch or proctor pipelines.
- (P1) As the platform owner, I want a nightly dashboard computing EQ-1…EQ-6 over shadow-vs-batch pairs, so that I can decide when parity justifies the Phase 2 flip.
- (P2) As an on-call engineer, I want lease/restart/429/drain behavior observable through
continuous_*operations, so that I can confirm the reliability machinery survives real prod restarts and rate limits before it owns the report. - (P2) As the platform owner, I want deterministic shadow selection with a ramp dial, so that I can grow coverage allowlist → 5% → 25% → 100% without ever half-analyzing a session.
- (P3) As a cost owner, I want per-shadow-session Gemini cost tracked against the P2-corrected pricing table, so that the scoring-plane budget is validated at real prod mix before Phase 2.
5d. New things to build
| Item | Kind | Responsibility | Reuses vs new |
|---|---|---|---|
| TUS hook fan-out | endpoint seam | Forward each post-finish hook to analyzer instances as a fire-and-forget hint (≤250 ms); zero change to existing hook behavior |
New seam in existing tus.py handler |
analyzer_sessions writer |
module | Lease/fencing/state-machine row per session (LIVE→DRAINING→DONE / BATCH_ONLY) | Reuses Phase 0 skeleton |
analysis_segments writer |
module | Per-(tasksession_id, task_id) 20-min scoring-plane unit; segment MAP result + status + lease_epoch |
Reuses Phase 0 skeleton |
continuous_shadow_runs table + DAO/model |
table | Shadow result row: total_score, sub_dims, coverage_pct, segment_count, drain_seconds, wall_clock_seconds, cost_summary, prompt_version, config, status |
New |
| Shadow writer + S3 dump | module | Assemble the would-be v2 report, write EQ inputs to continuous_shadow_runs and full JSON to traces/continuous/shadow_result.json; canary-block any result_analysis write |
New |
| Live scoring driver | module | Fire segment MAP as 1200 s of per-task footage closes; tail MAP + v2 REDUCE at drain | Reuses run_v2_map_unit / run_v2_reduce (synthesis.py:932-1053) via build-time COPY of airflow/dags/video_analysis |
| Token-bucket rate limiter | module | Client-side RPM+TPM pacing (Flash and Pro buckets) sized to P3 quota; 429 circuit breaker with backoff | New |
| Isolated Gemini key wiring | config/CI | GEMINI_API_KEY_CONTINUOUS in a separate GCP project; blast-radius isolation |
New (P3 provisions project/key) |
| Shadow selection | module | hash(tasksession_id) % 100 < CONTINUOUS_SHADOW_PCT OR position_id ∈ CONTINUOUS_SHADOW_POSITION_IDS |
New |
continuous_shadow_report DAG |
CI/Airflow | Nightly EQ-1…EQ-6 aggregation → S3 markdown + Discord embed | Reuses postmortem DAG / analysis_report.py pattern |
continuous_* structured logging |
observability | Per-session drilldown to Better Stack | Reuses runner.py:35-90 / LOGTAIL_SOURCE_TOKEN_* pattern |
5e. Interfaces and seams touched in existing code
- TUS post-finish hook —
fastapi_service/routers/tus.py:152-274: add a fire-and-forget fan-out branch gated onFF_CONTINUOUS_L1+ shadow selection. Existing self-copy / cover-video handling /{}response untouched; ≤250 ms budget so hook latency (0 failures / 23,372 calls, peak 1.4 hooks/s) cannot regress. - Scoring library — build-time COPY of
airflow/dags/video_analysis/**into the service image (same Dockerfile move as the Fargate runner); reuserun_v2_map_unitandrun_v2_reduce(synthesis.py:932-1053) verbatim. CI path filter must watch that directory for both images (the "one pipeline, two runners" rule in the Shared mechanisms primer). - Prod-state reads — read-only PostgREST embeds (position config, task blobs,
screenshare_timeline) using the DAG'sresolve_scoring-style patterns; no writes to anytask_sessionscolumn. - Submit endpoints are NOT touched —
fastapi_service/routers/v2/task_sessions.py:482-488(GITHUB/complete) and:910-916(ZIP/submissions) still calltrigger_dag(..., dag_run_id=client_submission_id)exactly as today. The resolver//finalizeseam is a Phase 2 change; stating this plainly is part of the contract. - Observability — Better Stack via the existing
operation+task_session_idstructured-log convention; nightly DAG modeled on the existing Discord-embed postmortem DAG.
5f. Data-model delta → Key Entities
Phase 1 introduces three service-role tables (full DDL in the design doc's "Data model" section; standard RLS-4, explicit FK ON DELETE, migrations via supabase migration new):
analyzer_sessions— one row pertasksession_id; lease (claimed_by,lease_expires_at, heartbeat),lease_epochfencing token, state machine,config_snapshot(window/segment sizes frozen at session start), 429 circuit-breaker counters.analysis_segments— the scoring-plane 20-min unit, identity(tasksession_id, task_id, segment_index); MAP result JSONB, status,lease_epoch,prompt_version,model,token_usage.continuous_shadow_runs— shadow-only result + EQ metrics per(tasksession_id, task_id); the row the nightly dashboard joins againsttask_sessionsonce batch reachesANALYSIS_DONE.
No chunk-ledger table is introduced: per design decision D8, chunk hooks are not durable — S3 is the source of truth and a 60 s S3 LIST sweeper over each instance's leased sessions is the guaranteed reconciliation path. L1/L2 window rows, session_digests, candidate_nudges, and chat tables are Phase 3.
5g. Shared mechanisms in play
| Mechanism | Role in Phase 1 |
|---|---|
| Lease + fencing | Core of the phase — one owner per session on analyzer_sessions; lease_epoch fences zombie writes. This is exactly what the soak validates in prod. |
| CAS | Guards the lease claim (WHERE claimed_by IS NULL OR lease_expires_at < now()) and each segment terminal write (WHERE status='ANALYZING' AND lease_epoch=:e). |
| Two runners | The service and Fargate build-time-COPY the same video_analysis pipeline; Phase 1 is the live proof the two runners produce equivalent scores. |
| Segment-index stamp | Idempotent per-task footage arithmetic (1200 s → segment boundary); the scoring-plane analogue of window_index. |
| Digest / version stamp | Not in play — the running digest and session_digests.version are awareness-plane, deferred to Phase 3. |
Per R-MULTITASK, all identity is per (tasksession_id, task_id): segments close on 1200 s of that task's footage, and a task switch closes the open segment early.
5h. Exit gate — Measurable Success Criteria
Advance to Phase 2 only when all hold (EQ metrics are defined in the design doc's "Migration & validation" section and Appendix D — that copy is authoritative):
| # | Criterion | Threshold |
|---|---|---|
| G1 | Soak duration + volume | ≥ 1 week continuous operation and ≥ 200 shadow sessions, spanning ≥ 10 positions, both mechanisms (GITHUB/ZIP), both length strata, incl. ≥ 10 COMPLETED_NOT_SUBMITTED expiry and ≥ 5 stop/restart (multi-prefix) sessions |
| G2 | EQ-1 total score | median |Δ| ≤ 5, P95 |Δ| ≤ 12, mean signed within ±3 (and within replicate multiples) |
| G3 | EQ-2 sub-dimensions | within-1 ≥ 90%, exact ≥ 55%, median Spearman ρ ≥ 0.80, NA-confusion ≤ 5% |
| G4 | EQ-3 recruiter decision | Kendall τ ≥ 0.85 per position (≥ 5 sessions); recommended-flip ≤ 5%, every flip human-reviewed against video |
| G5 | EQ-6 operational | shadow drain→report P95 ≤ 12 min in prod conditions; coverage ≥ 90% avg with ≥ 95% of sessions above the line; 0 sustained 429s |
| G6 | Quota fit | measured continuous RPM/TPM per concurrent session published and fits the P3-provisioned quota with headroom; derived max-concurrent number set (feeds the K-8 clamp) |
| G7 | Cost | per-shadow-session Gemini cost within budget at prod mix, against the P2-corrected pricing table |
| G8 | Reliability observed clean | ≥ 1 deliberate mid-drain service restart → clean lease reclaim + resume; ≥ 1 real 429 event → breaker fired, segments deferred, no batch/proctor starvation; last-100-sessions EQ re-check green |
| G9 | Zero-harm | zero prod incidents attributable to shadow (hook latency, DB load, S3, Gemini quota); zero result_analysis writes from the service (canary never converted to a real write) |
5i. What you watch during the soak week
- Better Stack
continuous_*operations (per-session drilldown):continuous_segment_map_done,continuous_drain_start,continuous_shadow_finalize,continuous_coverage_gap,continuous_circuit_open,continuous_lease_reclaim,continuous_budget_warning. - Nightly
continuous_shadow_reportDAG: rolling EQ-1…EQ-6 aggregates →s3://{bucket}/dag_reports/continuous_shadow/{date}.md+ Discord embed; join iscontinuous_shadow_runs×task_sessionsfor sessions that reachedANALYSIS_DONE. - Weekly human review: the EQ-3
recommended-flip list and the EQ-5 citation samples reviewed by hand; findings logged in the shadow report. - Capacity telemetry:
usage_metadataRPM/TPM per concurrent session vs the P3 quota; per-session cost subtotals against the P2 pricing table. - Coverage distribution: characterize the sub-90% population (crash-reload gaps, stop/restart, straggler uploads) — this is the population the Phase 2 auto-fallback rule must absorb.
5j. Failure modes handled + kill switches
- Sentinel hazard (R-1) — the canary assertion + the shadow-only write path (
continuous_shadow_runs+ S3) are the primary defense; a code-review checklist item backs it. FF_CONTINUOUS_L1=false(K-1) — mirrored on FastAPI and the service (theFF_FARGATE_TASK_ANALYSISprecedent,shared/feature_flags.py:24-33); stops the hook fan-out and all new shadow work. Fire-drilled once in Phase 1 (dev, then a controlled prod window).CONTINUOUS_SHADOW_PCT/CONTINUOUS_SHADOW_POSITION_IDS— deterministic ramp dial; drop to 0 to quiesce shadow instantly without a redeploy.- Gemini circuit breaker (K-7) — sustained 429s pause launches with backoff, mark affected segments deferred, alert Discord; low-coverage sessions simply show in
continuous_shadow_runs(no fallback needed — batch still owns the report). - Concurrency clamp (K-8) — env dial on max concurrent live sessions; sessions beyond it are not shadow-selected (deterministic hash ordering) and run pure batch.
Every kill switch leaves a fully working system, because batch is authoring every report regardless of what shadow does.
5k. Dependencies
- Phase 0 — Build & Replay (offline): the service skeleton, the COPYd scoring plane, lease/fencing, and the replay harness must be green (EQ-1…EQ-5 pass on the ≥50-session replay corpus; fault-injection suite clean).
- P3 (blocking) — separate GCP project +
GEMINI_API_KEY_CONTINUOUS, and the measured Gemini RPM/TPM quota that sizes the rate limiter. Shadow ≈ doubles Gemini load; without P3 the soak is unsafe. - P2 — fix
GEMINI_FLASH_PRICINGforgemini-3-flash-previewand include timeline/tags tokens incost_summary, so shadow cost metrics (G7) are trustworthy. - P4 — private ACL on
traces/continuous/**(should already be in place from Phase 0; shadow writes theshadow_result.jsondump under it). - P1 and P5 are not Phase-1 dependencies (skill scopes are awareness-plane / Phase 3; the MP4 stream-copy spike is a Phase 2 playback concern).
5l. Assumptions
- The submit → DAG seam stays untouched, so batch remains the sole author of every prod artifact throughout Phase 1.
- P3 quota is provisioned before shadow ramps past the allowlist, and shadow's ~2× Gemini load fits under it with headroom.
PROMPT_VERSIONis pinned identical (2.6.0) across batch and shadow for the entire soak — any prompt bump resets the comparison clock (the EQ precondition in the design doc).- Prod chunk keys and ordering match the documented shape (
screensharesession_prefix, per-task{i}_{task_id}folders, page-global counter); per-task footage arithmetic drives segment closes. - Prod live-session volume is low (≈ 1 concurrent Fargate today), so reaching ≥ 200 shadow sessions may require the full ramp to 100% and may run beyond one calendar week — G1 requires both the ≥ 1-week soak and the ≥ 200-session count, whichever lands later.
- S3 chunk lifecycle does not delete chunks mid-soak (verified as a Phase 0 sign-off item).
5m. Spec-kit slicing note
Phase 1 is one spec-kit feature, most naturally folded into the same feature as Phase 0 — the suggested "continuous scoring service — shadow" — with Phase 1 contributing a distinct user-story cluster (prod wiring, deploy, soak/observe). If the team prefers a clean cut, split it as a thin follow-on feature that depends-on the Phase 0 feature; the spec boundary then sits between offline mechanism (Phase 0: service skeleton, COPYd scoring library, replay harness, fault injection) and prod wiring (Phase 1: hook fan-out, continuous_shadow_runs + writer, rate limiter, isolated key, selection, dashboards). Phase 1's Functional Requirements cover the fan-out seam, the shadow store and its never-write-result_analysis invariant, the token-bucket limiter + circuit breaker, deterministic selection, and the nightly EQ dashboard; the EQ gate and Measurable Success Criteria are shared with Phase 0's replay (one yardstick, two runners). Sequencing is near-serial after Phase 0: the fan-out + shadow store + writer land first (they gate any prod signal), then the rate limiter + isolated key (they gate ramping past the allowlist), then the dashboards (they gate the exit decision) — a realistic team-of-1-2 ordering. Next free spec number is illustrative; confirm at build time.
6. Phase 2 — Primary (report flip)
Phase 1 — Shadow (prod, silent) ran the scoring plane live against real sessions and wrote to continuous_shadow_runs only, proving the continuous report is equivalent to batch by construction. Phase 2 spends that proof: the service becomes the primary author of task_sessions.result_analysis, the Fargate DAG drops to standby/fallback, and the recruiter report lands at T+4-9 min instead of T+30-45. Nothing candidate-facing changes; no new tables land. This is the one phase that flips who writes the crown-jewel column, so it is built around a fallback that Phase 1 already made trustworthy.
Decision. Phase 2 ships latency, not accuracy. The scoring plane uses the same CV activity-timeline pre-pass and the same v2 MAP/REDUCE as batch (prompt 2.6.0, pinned identical), so scores are equivalent by construction — the parity Phase 1 measured. Any timeline-fidelity or accuracy change is deferred to the awareness plane (Phase 3) and any scored-shape change to Phase 5.
result_analysisstays byte-identical (R-SHAPE): no new keys, noschema_versionbump.Decision. There is no separate fallback DAG. Submit always triggers the same
tasksession_completion_fargateDAG withdag_run_id = client_submission_id; the Gemini sentinel (task_analysis_runner/core.py:1626-1641) makes that run a ~1-2 min no-op when the service already finalized, or a full batch analysis when it did not. Fallback is a property of not removing today's submit→DAG trigger, not of new code.
(a) Goal and the value it ships
The recruiter opens a candidate's report minutes after submit rather than half an hour later, and reads exactly the same report they read before — same Appendix-A v2 shape, same numbers within the Phase 1 noise floor. Operationally, the batch pipeline is now the accelerator's safety net rather than the primary path, and it activates with zero human involvement whenever the live path is unavailable or a session's footage coverage is too low to trust. The value is speed with no correctness give-up: a submission that the service cannot finalize is still scored by batch, indistinguishably from today.
(b) Scope — in and explicitly out
| In scope (Phase 2) | Out of scope → owner phase |
|---|---|
resolve_analysis_trigger seam + FF_CONTINUOUS_PRIMARY |
L1/L2/digest awareness windows → Phase 3 — Awareness plane |
Service POST /finalize: 5s ack, ≤120s drain barrier, tail MAP + v2 REDUCE as a library |
Bar Raiser nudges + candidate chat → Phase 3 — Awareness plane |
| Sentinel-no-op Fargate hop so cert/notify/recommend run once | Coachability as a scored bucket / any result_analysis shape change → Phase 5 |
Coverage < 90% → batch auto-fallback (K-5) + reconciler watchdog |
Expiry fan-out moved onto continuous → Phase 4 — Batch backfill-only |
| Recruiter-playback MP4 builder (stream-copy, from P5) | Cheaper single-plane scoring from L2 windows → Phase 5 |
pipeline_metadata.continuous stamp (run metadata, alongside result_analysis) |
Nudge/chat report addendum + Bar Raiser badge → Phase 3 — Awareness plane |
(c) Suggested user stories (P1/P2/P3)
| Pri | Story |
|---|---|
| P1 | As a recruiter, I want the task report to land ~4-9 min after the candidate submits (not 30-45), so I can act on results in the same working session. |
| P1 | As a platform operator, I want the batch DAG to still produce every report automatically when the live service is down or a session's coverage is low, so no submission is ever left unscored. |
| P1 | As a recruiter, I want the faster report to be byte-identical in shape and within-noise-floor in content to the batch report, so nothing about how I read it changes. |
| P2 | As a platform operator, I want one flag (FF_CONTINUOUS_PRIMARY=false) to instantly revert report production to batch with no redeploy, so rollback is a config flip. |
| P2 | As a recruiter, I want the report's screenshare playback video present and correct even though the live path finalized the score, so I can review the candidate's work. |
| P3 | As a platform operator, I want a reconciler that finds any session still without a report after 20 min and re-triggers analysis, so no session silently falls through the seam. |
(d) New things to build
| Item | Kind | Responsibility | Reuse vs new |
|---|---|---|---|
resolve_analysis_trigger(tasksession_id) -> "continuous"|"batch" |
module (shared/feature_flags.py) |
pick the path at submit, next to task_completion_dag_id() |
new, mirrors FF_FARGATE_TASK_ANALYSIS |
FF_CONTINUOUS_PRIMARY |
flag / config | report-source kill switch; env in FastAPI + service (mirrored, I-7) | new |
service POST /v1/sessions/{tsid}/tasks/{task_id}/finalize |
endpoint | 5s ack; async drain + finalize owns the run | new (service) |
finalizer (drain barrier + finalize sequence) |
module (session_analyzer/core/finalizer.py) |
tail MAP → v2 REDUCE → update_supabase-equivalent → finalize_task_session RPC |
reuse video_analysis + core.py functions as a library, not a rewrite |
| sentinel-no-op hop trigger | wiring | trigger the same DAG, dag_run_id=client_submission_id |
reuse airflow_utils.trigger_dag |
| coverage eligibility + HANDOFF | module | coverage < CONTINUOUS_MIN_WINDOW_COVERAGE_PCT (90) → batch |
new (reads analyzer_sessions / analysis_segments) |
| reconciler watchdog | module + cron/CI (10-min cadence) | find COMPLETED > 20 min sessions lacking a report → re-trigger |
new, expiry-DAG precedent |
| recruiter-playback MP4 builder | module (video_builder) |
incremental ffmpeg -c copy concat → screenshare_video at drain |
new build, gated on the P5 stream-copy spike |
pipeline_metadata.continuous stamp |
data (JSON block) | analysis_source, window/segment config, coverage — alongside result_analysis |
new stamp (R-SHAPE) |
(e) Interfaces and seams touched
Both submit endpoints call trigger_dag(task_completion_dag_id(), {tasksession_id, task_id}, dag_run_id=client_submission_id) today — fastapi_service/routers/v2/task_sessions.py:482-488 (GITHUB /complete) and :910-916 (ZIP /submissions). Phase 2 inserts the resolver in front of that call (the batch branch stays byte-for-byte). The E2B /v2/sessions/{id}/submit is left untouched — it deliberately never triggers (the 2026-06-02 double-fire incident). The expiry fan-out keeps its own expiry-{tsid}-{tid} id and stays pointed at batch in Phase 2 (moves to continuous in Phase 4).
def resolve_analysis_trigger(tasksession_id) -> str: # shared/feature_flags.py
if not is_enabled("FF_CONTINUOUS_PRIMARY"):
return "batch"
if not service_was_live_consuming(tasksession_id): # the Phase-1 ingest flag / eligibility;
return "batch" # nothing to drain otherwise
return "continuous"
# submit endpoint, after the existing status flips:
if resolve_analysis_trigger(...) == "continuous":
resp = POST service /v1/sessions/{tsid}/tasks/{task_id}/finalize
body {client_submission_id, skip_status_update: false} timeout 5s # ack only
if resp in (200, 202): return # service now owns the run (hop + fallback)
trigger_dag(task_completion_dag_id(), conf, dag_run_id=client_submission_id) # batch, soft-fail as today
The service-side finalize (async after the 202) is: eligibility (live state + coverage ≥ 90%, else HANDOFF) → drain ≤ DRAIN_GRACE_SECONDS (120) for straggler chunks → tail-segment MAP + run_v2_reduce (synthesis.py:932-1053) → combine_v2_buckets (frozen shape, result_combiner.py:374-463) → the exact core.update_supabase write sequence (core.py:2642-2878: result_analysis via upsert_task_analysis_entry with task_id stamped, pipeline_metadata, score, utkrusht_verified_skills, screenshare_video) → finalize_task_session RPC honoring skip_status_update (core.py:2897-2899) → utkrusht_score post-finalize write (core.py:2948-2971) → trigger the DAG hop. On any failure or HANDOFF, the identical trigger_dag(dag_run_id=client_submission_id) runs the full batch pipeline. The hop's read_outcome reconstructs won from DB state (task_analysis_fargate_dag.py:426-505), so certificate (deduped, :180-209), notification, and recommended recompute fire once against the rows the service wrote — no side-effect code is duplicated in the service.
client_submission_id: the sentinel makes it a no-op after a live finalize, or the full batch fallback otherwise. The two grey "DAG" boxes are the same run (409-deduped).The idempotency spine. One client_submission_id per (session, task) — minted in FE localStorage and passed in the submit body (usePerTaskSubmit.ts:18-29) — is the shared key across FE retries, service job dedup, Airflow dag_run_id 409-as-success (shared/utils/airflow_utils.py:89-127), and the reconciler. No second namespace is introduced at submit; the only other is the pre-existing expiry-{tsid}-{tid}.
| Race / retry | Why it is safe |
|---|---|
| FE retries submit → endpoint calls the service twice | Service dedupes on (tasksession_id, task_id); the second call returns the in-flight/finished job (200 {already:true}) — the 409-as-success mirror |
| Service triggers the hop AND the endpoint fell back to a direct trigger (5s-timeout race) | Both use dag_run_id=client_submission_id → Airflow 409 → success; exactly one DAG run exists |
| Fallback batch starts while a slow service finalize also completes | upsert_task_analysis_entry replaces per-task_id; finalize_task_session returns already_finalized to the loser — wasted spend at worst, never a corrupt report |
| Batch starts, service has not written, service then dies | Batch performs the full analysis — indistinguishable from today |
| Reconciler double-fire | Watchdog re-triggers with the client_submission_id read from task_submissions[task_id] (best-effort FE write, markTaskSubmitted), else reconcile-{tsid}-{tid}; a duplicate run is cost, not corruption |
(f) Data-model delta → Key Entities
No new tables. Phase 2 reuses analyzer_sessions (lease/state) and analysis_segments (scoring-plane 20-min units) introduced in Phase 0/1; see the Data model section for the full DDL. The deltas are write targets, not schema: the service now writes the real task_sessions.result_analysis (byte-identical shape) plus score, utkrusht_verified_skills, screenshare_video, and a new pipeline_metadata.continuous JSON block (analysis_source, window/segment config, coverage_pct) that rides alongside result_analysis, never inside it (R-SHAPE). Key Entity for spec purposes: pipeline_metadata.continuous (run-metadata block), plus client_submission_id as the cross-system idempotency key.
(g) Shared mechanisms in play
Refer to the Shared mechanisms primer for definitions; Phase 2 exercises:
- One pipeline, two runners — load-bearing here:
/finalizeimports the v2 REDUCE +core.pyfinalize sequence as a library; the Fargate image and the service image both build-time-COPYairflow/dags/video_analysis/**(one CI path filter watches both), so batch and live never drift. - Lease + fencing —
analyzer_sessionsgives one owner per session's finalize; a racing batch fallback is arbitrated downstream by the RPC + sentinel, not by the lease. - Three stamps —
segment_index/window_index(per-task footage arithmetic, idempotent) gate re-MAP;pipeline_metadata.continuousis the run stamp;lease_epochfences zombie writers. - CAS — light: segment/finalize claims are conditional updates. The digest fold and its CAS are not in play (Phase 3).
client_submission_ididempotency spine — the shared key across every trigger site (see the table above).
(h) Exit gate — Measurable Success Criteria (the soak-week observations)
| # | Condition | Threshold to advance |
|---|---|---|
| G1 | Report latency, submit → ANALYSIS_DONE (EQ-6) |
P50 ≤ 8 min, P95 ≤ 12 min, held over the soak window |
| G2 | Recruiter-visible discrepancy vs Phase 1 shadow parity | zero; result_analysis byte-identical; reversed-shadow EQ-1…EQ-6 green |
| G3 | Auto-fallback rate | < 5% daily, population characterized |
| G4 | Kill-the-service fire drill | batch produces the report under the same client_submission_id |
| G5 | R-MP4 + R-PROCTOR | screenshare_video + proctor_video present on 100% of sessions |
| G6 | Reconciler | deployed + fire-drilled; no submitted session unscored by T+45 min |
| G7 | Duplicate-run rate under FE-retry storms | within its monitored bound (idempotency spine holds) |
| G8 | Soak duration | ≥ 1 week minimum; reversed-shadow 10% continues ≥ 4 weeks into Phase 3 entry |
Warning. No Phase 2 flip until R-MP4 and R-PROCTOR hold on 100% of shadow sessions. The sentinel skips before ffmpeg (
core.py:1650-1698runs after it), so on a no-op hop the batch container will not build the combined MP4 — the service must producescreenshare_videoitself via incremental-c copyconcat (the same mechanism the proctor path uses), finalized at drain. Proctor concat has no sentinel, soproctor_videois re-built by the hop for free — verify it in shadow.
(i) What you watch during the soak week
- Reversed shadow: run batch in shadow on a 10% sample for the first 4 weeks (force-flags writing to
continuous_shadow_runswith roles reversed), so EQ metrics keep accumulating with continuous now primary. The nightlycontinuous_shadow_reportDAG compares continuous-primary vs shadow-batch and posts a Discord embed. - BetterStack
continuous_*operations:continuous_drain_start,continuous_finalize,continuous_coverage_gap,continuous_circuit_open, plus per-stagelatency_ms— the query surface for the latency, coverage, and cost dashboards. - Alert thresholds: fallback rate
> 5%daily → investigate;> 15%→ auto-page and consider flag-off; any session with no report by T+45 → page (the reconciler should have caught it by T+20); duplicate-run rate alert (R-9); Gemini-budget breach. - Distributions: submit→
ANALYSIS_DONEpercentiles, coverage histogram, cost/session at the fixed P2-corrected pricing tables reconciled against the monthly Google bill.
(j) Failure modes handled + kill switches
| Switch / mode | Effect |
|---|---|
K-2 FF_CONTINUOUS_PRIMARY=false |
submit endpoints revert to the exact current trigger_dag call; in-flight service finalizes complete harmlessly (their writes are the writes batch would make; the RPC arbitrates) |
K-5 coverage < 90% → HANDOFF |
that session's report is produced by the batch DAG under the same client_submission_id; automatic, no human |
| K-1 ingest flag off | resolve_analysis_trigger returns "batch" (nothing was consumed to drain) |
| K-7 Gemini circuit breaker / K-8 concurrency clamp | live calls pause / sessions beyond the clamp run pure batch; K-5 catches any that lapse at drain |
K-9 FF_FARGATE_TASK_ANALYSIS |
unchanged — steers the batch fallback itself between Fargate and the legacy DAG |
| Reconciler watchdog | catches a missed finalize (service died between ack and write) and re-triggers |
K-2 and K-5 are each fire-drilled once in early Phase 2 (K-5 occurs naturally; K-2 in a controlled window). Load-bearing risks retired here: R-4 (missing playback/proctor video → R-MP4/R-PROCTOR gate), R-5 (utkrusht_score/tags drift → library-reuse mandate; spot-check score, utkrusht_verified_skills, utkrusht_score), R-9 (triple-fire cost blowout → idempotency spine).
(k) Dependencies
- Phase 1 — Shadow (prod, silent) exit gate green: EQ-1…EQ-6 over ≥200 sessions / 3 weeks including the last-100 re-check. The parity proof is the entry condition; Phase 2 does not re-litigate accuracy.
- P5 (stream-copy concat spike) — blocking: gates the recruiter-playback MP4 builder / R-MP4.
- Phase 0 — Build & Replay fault-injection + reconciler machinery — reused to run the kill-the-service fire drill (G4).
- P2 (fix
GEMINI_FLASH_PRICING+ include timeline/tags tokens incost_summary) — carried from Phase 1 so cost dashboards are trustworthy at the flip. - The prerequisite workstreams (P1-P5) land in parallel; only P5 (and the Phase 1 gate) block Phase 2.
(l) Assumptions
- Phase 1 shadow proved the continuous report equivalent to batch (scores equivalent by construction, same pinned prompt 2.6.0 and calculator).
- The Gemini sentinel behaves as documented (
core.py:1626-1641): any non-placeholderresult_analysisentry → the batch container skips chunk download, ffmpeg, and Gemini and exits in ~1-2 min. client_submission_idis reliably minted and passed by FE (usePerTaskSubmit.ts:18-29); the reconciler absorbs the best-efforttask_submissionswrite gap.- Fargate cap=1 is acceptable because the report is already live —
ANALYSIS_DONEis flipped by the service and the notifications microservice fires on the Realtime status change independently of Airflow; only cert/postmortem/recommendedlag, and that is minutes-tolerant. - Coolify env-flag semantics (minutes to take effect) apply to K-2.
(m) Spec-kit slicing note
Phase 2 is one spec-kit feature — "continuous scoring — primary + finalize seam" (suggested next free number per the plan's numbering note; confirm at build time). The finalize seam (resolver + FF_CONTINUOUS_PRIMARY + service /finalize + drain + library-reuse finalize + sentinel hop + HANDOFF) is the single spine; the recruiter-playback MP4 builder and the reconciler watchdog are P2/P3 user stories within that feature, not separate features. P5 (the stream-copy spike) is a standalone prereq PR that lands before the feature spec. There is no data-model.md delta beyond the pipeline_metadata.continuous stamp, so the spec's Key Entities section is short and its FRs concentrate on the seam contract, idempotency, and the exit-gate SLOs. Sequencing is near-serial for a team of 1-2: P5 spike → playback builder + /finalize finalizer (reusing Phase 0/1 code) → resolver + reconciler → fire drills → soak.
7. Phase 3 — Awareness plane (Bar Raiser + chat)
Phase 3 adds the new awareness plane on top of the plumbing the first three phases proved: the session-analyzer service, the lease/CAS/heartbeat ownership from the shared-mechanisms primer, the S3 chunk sweeper, and (from Phase 2) a live scoring plane that is already the primary report producer. Everything here is additive and fail-soft — the recruiter report shape is untouched — but it is also the highest-scrutiny phase, because for the first time the system speaks to the candidate. A wrong nudge or a leaked hint is the most visible failure the platform can produce, so this phase ships last, behind a hard pre-launch gate and a per-position opt-in.
Decision. Phase 3 is candidate-facing and gated twice. Before the first real candidate sees a nudge, ≥300 human-reviewed simulated nudges must show zero solution leakage through the guardrail. Then the plane turns on per-position (
positions.nudges_enabled, forward-only) on a few friendly design-partner positions, watched for guardrail veto rate and nudge quality — never a silent backfill of existing positions.Decision. The report stays byte-identical (R-SHAPE). Nudges and chat reach recruiters through a report addendum read path that composes
candidate_nudges+task_session_chat_messages+ apipeline_metadata.continuousblock alongsideresult_analysis, not inside it. No newresult_analysiskeys, noschema_versionbump, no 4th bucket in this phase. Coachability as a scored bucket (FR-041 renormalization) is explicitly Phase 5.
7a. Goal and the value it ships
Phase 3 turns the passive live pipeline into a live observer that can help. It builds the two-layer hierarchy (1-minute L1 video windows, 5-minute L2 text coalesce, a running per-task digest), then rides two consumers on top: a Bar Raiser nudge engine that asks a candidate one thought-provoking question when struggle has genuinely persisted, and a candidate chat that answers task and logistics questions while structurally refusing to hand over solutions. The value to the candidate is a fairer, less lonely assessment; the value to the recruiter is a verbatim, auditable "Bar Raiser transcript" panel plus a new qualitative coachability signal — all without changing a single scored number this phase. The mechanism detail lives in the design doc's "The Bar Raiser nudge engine" and "The candidate chat loop" sections; this section owns the build order, seams, and gates.
7b. Scope — in and explicitly out
| In scope (Phase 3 builds it) | Out of scope (and who owns it) |
|---|---|
| L1 1-min window analyzer (single-turn Flash, inline bytes, carry-chain) | Overlapped video windows — rejected in "The hierarchical window model" |
| L2 5-min coalesce + capped-rewrite digest fold (one combined Flash call, CAS-versioned) | A 3rd (15-min) layer — rejected; lookback reads 3 L2s directly |
| Bar Raiser nudge engine (deterministic gate → Flash judgment → fail-closed guardrail) | Coachability as a scored bucket → Phase 5 (result_analysis shape change) |
Candidate chat (send endpoint, analyzer_outbox, responder, dock, Realtime broadcast) |
Swapping the scoring MAP's timeline input from CV to L1 (timeline_source=l1) → gated accuracy follow-up (see 7l) |
| Report addendum read path + recruiter transcript panel | Any change to the scored MAP/REDUCE prompts (score parity preserved, R-AWARE-SCORING) |
positions.nudges_enabled opt-in + task_sessions.nudges_active stamp |
RLS-per-candidate hardening of chat tables → deferred with the S3 public-read revisit |
| Prompt 2.7.0 (chat-panel-on-screen note so scoring does not mis-read the panel) | Single-plane scoring-from-L2 (−62% cost) → Phase 5 candidate |
Persona prompt NUDGE_PROMPT_VERSION = 1.0.0 + guardrail veto prompt |
Sound/Web-Notification nudge alerts → post-v1, only on measured miss evidence |
7c. Suggested user stories (P1/P2/P3)
| Pri | Story |
|---|---|
| P1 | As a candidate stuck for ~15 min, I want one calm, non-judgmental question that redirects my attention, so that I can recover without being handed the answer. |
| P1 | As a candidate, I want to ask the Bar Raiser what the task requires and how much time I have left, so that I am not blocked by ambiguity I could have resolved. |
| P1 | As a hiring team, I want every nudge and chat message shown verbatim on the report with its evidence, so that I can see exactly what help the candidate received and judge fairly. |
| P2 | As an operator, I want to enable the Bar Raiser on one position at a time and kill it globally in one flip, so that I control blast radius during rollout. |
| P2 | As a candidate who ignores the chat, I want silence to cost me nothing, so that declining help is always a safe choice (no-penalty invariant). |
| P3 | As a prompt engineer, I want every gate decision, judgment, and guardrail verdict (including suppressed ones) persisted, so that I can tune nudge precision offline from real traces. |
7d. New things to build
| Item | Kind | Responsibility | Reuses vs new |
|---|---|---|---|
| L1 window analyzer | module | 60s stream-copy concat → single-turn Flash on inline bytes → ActivityBlock timeline + carry_out |
new; reuses timeline_schemas enums, cache-less gemini_client call |
| L2 coalesce + digest fold | module | 5-min text coalesce over 5 L1s (overlap-1) + capped-rewrite digest in one Flash call | new; digest fold-in is canonical (R-COST) |
session_digests |
table | versioned running digest per (tasksession_id, task_id); version == len(applied_l2) |
new |
candidate_nudges |
table | decision ledger incl. SILENT/suppressed + guardrail verdict, evidence, policy_hash |
new |
task_session_chat_messages |
table | chat transcript, service-role only, task_id NOT NULL, client_msg_id idempotency |
new |
analyzer_outbox |
table | reliable delivery of nudges/replies (write-then-deliver-then-stamp) | new; copies notifications/ inbound pattern |
| Nudge engine | module | deterministic gate → Bar Raiser Flash judgment → guardrail → send/drop | new |
| Guardrail validator | module | deterministic lint + separate Flash veto turn; fail-closed, one regen | new |
| Chat send endpoint | endpoint | POST /v2/task-sessions/{id}/chat-messages, first authenticated /v2 route |
new; wires the candidate-JWT TODO |
| Chat responder + broadcast | module | claim outbox → Bar Raiser reply → insert → Realtime broadcast chat:{tasksession_id} |
new; reuses claim contract |
| Next API routes (send / history / seen) | frontend | candidate JWT + session-ownership, service-role read | new; appendScreenshareEvent precedent |
BarRaiserChatDock + useBarRaiserChat |
frontend | collapsed dock, sonner toast, tab-title counter, subscribe + history | new (utkrushta-assessment) |
| Report addendum read path + recruiter panel | endpoint + frontend | compose the two tables + pipeline_metadata.continuous beside result_analysis |
new; recruiter transcript panel |
| Persona + guardrail prompts | prompt | NUDGE_PROMPT_VERSION = 1.0.0, verbatim in the design doc appendix |
new |
| Scoring prompt 2.7.0 | prompt | chat-panel-on-screen note in the scoring base blocks | new; bumps PROMPT_VERSION |
positions.nudges_enabled / task_sessions.nudges_active |
columns | per-position opt-in + creation-time stamp | new columns |
| Simulated-nudge harness + review UI | CI/tooling | drive ≥300 nudges over recorded sessions; human leakage review | new; extends the Phase 0 replay harness |
FF_BAR_RAISER_NUDGES, FF_CANDIDATE_CHAT |
config | global kill switches, one flip each | new |
7e. Interfaces and seams touched in existing code
- FastAPI /v2 gains its first authenticated endpoint: the chat send route wires the candidate-JWT dependency flagged TODO at
Utkrushta/fastapi_service/routers/v2/sessions.py:28; the Next hop attaches the HttpOnlycandidateLoginTokenas a Bearer header exactly as the flask-proxy does. - Candidate app task screen: mount
<BarRaiserChatDock/>as a sibling of theScreenShareDialoginstances inTaskQuestion.tsx(after:1087) so it is task-only and inheritsactiveTaskIdfor multi-task sessions with zero new plumbing. - Position flag read on the FE mirrors
positions.proctor_disabledatProctorTusRecorder.tsx:64-66(add the field besidesrc/types/Position.ts:86-87). - Realtime: the browser Supabase client is already tuned for far-from-Mumbai candidates (
timeout: 30_000,heartbeatIntervalMs: 25_000,client.ts:24-28); the dock inherits it. Per R-REALTIME the analyzer sends a service-role broadcast onchat:{tasksession_id}after each insert — notpostgres_changes— and history/reconnect fetch goes through the authenticated Next route. - Outbox copies the WhatsApp conversational-agent loop (
notifications/core/inbound_listener.py, migration20260517000001), re-keyed fromphone_numbertotasksession_id. - Scoring base blocks (
prompts/v2/base_blocks.py, composed atpipeline.py:681-706) gain the 2.7.0 panel note; this must land with the FE panel so the on-screen chat is not mis-scored. - Position skill scopes finally reach the model: the prerequisite
build_position_skill_scope_context()fills the dead{skills_to_be_checked}placeholder (v1/skill.py:46-47) into the awareness plane only — never scoring prompts (R-AWARE-SCORING).
7f. Data-model delta (Key Entities)
Phase 3 introduces four service-role tables — session_digests, candidate_nudges, task_session_chat_messages, analyzer_outbox — adds L1/L2 rows to the analysis_windows table stood up in Phase 0, and adds two columns (positions.nudges_enabled DEFAULT false, task_sessions.nudges_active). All identity is per (tasksession_id, task_id) (R-MULTITASK): L1/L2 window indexes are computed over that task's chunk sequence, digests and nudges are per-task, and chat rows carry task_id NOT NULL. Each table gets a DAO + Pydantic model, standard RLS-4, explicit FK ON DELETE, and a service-role posture; chat and nudge tables have no anon SELECT (R-REALTIME). Full DDL is in the Data model section.
7g. Shared mechanisms in play
| Mechanism | How Phase 3 uses it |
|---|---|
| CAS | session_digests.version advances by conditional UPDATE (WHERE version = N); double-apply is impossible, and any version != len(applied_l2) triggers a recompute from L2 rows. |
| Lease + fencing | The nudge engine and chat responder run only on sessions this instance holds the lease for; every write carries lease_epoch so a zombie writer's stale nudge lands 0 rows. |
| Digest | The running per-task fold is the awareness-plane top layer; it rides the L2 call (one combined Flash call per 5-min boundary, R-COST fold-in canonical). |
| Outbox | analyzer_outbox gives at-least-once nudge/reply delivery; the per-minute tick sweeps unclaimed/abandoned rows as the safety net. |
| Two runners | Untouched here — scoring still COPYs airflow/dags/video_analysis; the awareness plane is separate, service-owned code. |
7h. Exit gate — Measurable Success Criteria
The nudge caps the gate enforces (all config-driven per the _resolve_max_concurrent_analyses precedent, task_analysis_fargate_dag.py:141-177):
| Cap | Default | Purpose |
|---|---|---|
| Quiet head | 10 min | need ≥2 closed L2 windows before any pattern claim |
| Quiet tail | 5 min | a question in the last 5 min only adds stress |
| Cooldown | 10 min | max 1 nudge per 10 min; a nudge needs ≥2 windows to show effect |
| Session cap | 4 | scarcity keeps nudges meaningful |
| Struggle gate | ≥2 of last 3 L2s | a struggling minute is normal; a struggling quarter-hour is a pattern |
Advance out of Phase 3 (per-position, then broadly) only when all hold:
| # | Condition | Target |
|---|---|---|
| G-1 | Human-reviewed simulated nudges with zero solution leakage through the guardrail | ≥300, 0 leaks — pre-real-candidate gate |
| G-2 | Guardrail veto rate on real candidates | tracked, stable; no leaked message reaches a candidate |
| G-3 | Chat reply latency | p50 ≤8s, p95 ≤15s |
| G-4 | Nudge freshness / gap safety | 0 nudges built on >40%-gap lookback; 0 nudges after submit/terminal |
| G-5 | Per-session awareness cost | ≤ $2.50 cap; P95 within the +35–45% two-plane envelope |
| G-6 | Report parity | scored result_analysis byte-shape unchanged vs Phase 2; addendum renders on the recruiter panel |
| G-7 | No-penalty invariant | a candidate with zero interactions scores identically to the Phase-2 three-bucket math |
7i. What you watch during the soak week
BetterStack continuous_* operations carry task_session_id and latency_ms per stage: continuous_l1, continuous_l2, continuous_digest, continuous_nudge_gate, continuous_nudge_decision, continuous_guardrail_veto, continuous_chat_reply, continuous_chat_veto, continuous_injection_suspected. Dashboards: nudge send rate and SILENT-reason distribution; guardrail veto rate (the leakage tripwire); chat reply p50/p95 and policy_action mix (answered/deflected/refused); per-session awareness spend against the $2.50 cap; L1/L2 window coverage and freshness lag. Comparisons: nudged vs un-nudged cohorts kept separable by the report badge (never mixed silently); the ≥300 simulated-nudge corpus re-scored against the current guardrail on every prompt change; S3 traces at tasksessions/{id}/traces/continuous/... (private ACL) for offline precision tuning.
7j. Failure modes handled + kill switches
- Fail-closed guardrail — deterministic lint then a separate Flash veto turn that sees only the task title/outcomes + pending message + proposed reply (it cannot be socially engineered by session content it never reads); any error anywhere drops the message. A lost nudge is recoverable next tick; a leaked solution is not.
- Capability isolation —
tasks.solutionsis structurally absent from every awareness-plane context (the builder never selects the column), asserted by a canary-string unit test. A perfect jailbreak can leak only what the candidate already has. - Freshness + gap gates — skip (SILENT, audited) if the newest L2 is older than 2×L2 or lookback coverage <60%; force SILENT if >40% of the lookback is gaps. Placeholders read as "unknown," never "idle."
- Cost cap + degradation ladder — at the per-session $2.50 cap or a 429/spend circuit-breaker trip, suspend nudges/chat → widen L1 → fall back to batch; the chat panel switches to the neutral "your reviewer has stepped away" state and still persists messages (R-OPS).
- Never writes
result_analysis— the awareness plane cannot corrupt the report or trip the Gemini sentinel (R-IDLE).
Kill switches (each fire-drilled once this phase): FF_BAR_RAISER_NUDGES (global nudge kill), FF_CANDIDATE_CHAT (global chat kill), positions.nudges_enabled (per-position, forward-only), the automatic Gemini 429/spend breaker, and the concurrency clamp.
7k. Dependencies
- P1 (position skill scopes) — REQUIRED, blocking. The Bar Raiser cannot judge "wrong direction for this position" without it, and it must reach the awareness plane only.
- Phases 0–2 complete: the service skeleton, lease/CAS/heartbeat, the chunk ledger + 60s S3 sweeper, and live scoring already primary.
- P3 (separate GCP project +
GEMINI_API_KEY_CONTINUOUS): awareness-plane calls must not share the batch/proctor quota bucket. - Recommended before the candidate-facing channel: the TUS hook bearer token + pre-create validation (failure-modes F1.8), and re-enabling FE screenshare drain/crash recovery (recording-ingest §8.5) to shrink terminal-gap tails.
- Prompt 2.7.0 must ship with the chat panel so the scored MAP does not mis-read the on-screen chat.
7l. Assumptions
- The Gemini ~20MB inline-request cap holds, keeping L1 at 60s single-turn on inline bytes (validated in Phase 0 replay); single-turn Flash L1 fidelity is sufficient for nudge signal.
- The anon-key access model is acceptable for chat via service-role-only tables + broadcast delivery + an authenticated history route; per-candidate RLS hardening is a deferred follow-up, not a Phase-3 blocker.
- Scoring keeps
timeline_source=cvin Phase 3 so the report stays byte-identical to Phase 2; swapping the scoring MAP to the L1 timeline (timeline_source=l1, the R-AWARE-SCORING end-state with its 90%-coverage CV fallback) is a gated accuracy change out of scope here. - Friendly design-partner positions are available for opt-in;
change_densitystruggle floors are calibrated from a prod-timeline histogram before launch (nudge-persona §5.3); candidate consent/disclosure copy is approved by legal, and the no-penalty invariant is stated to candidates.
7m. Spec-kit slicing note
This phase is best cut into three spec-kit features on a near-serial spine, with the ≥300-simulated-nudge zero-leak gate blocking both consumers from reaching a real candidate:
- Awareness ingest — L1/L2/digest foundation. L1/L2 rows on
analysis_windows,session_digests(CAS-versioned), the flag columns, and window/coverage observability. Ships and soaks on the replay harness first (window quality) before any consumer. Can also open the nudge-engine spec as its data dependency. - Bar Raiser nudge engine.
candidate_nudges, the gate → judgment → guardrail pipeline, persona prompt1.0.0, and the nudge half of the report addendum. Owns the ≥300-simulated-nudge gate. - Candidate chat.
task_session_chat_messages,analyzer_outbox, the authenticated send endpoint + responder, the dock, Realtime broadcast, and the chat half of the addendum.
The shared seam across features 2 and 3 — the fail-closed guardrail, the persona/answer-policy prompt, and the addendum read path — should be specified once (in feature 2) and consumed by feature 3, so the two specs do not duplicate the guardrail contract. Realistic team-of-1–2 ordering: P1 → foundation → guardrail + chat (reactive, answers only when asked — a safer surface to validate the answer policy on) → nudge engine (proactive, the high-scrutiny gate). The prerequisite fixes P1 and P3 are small standalone PRs that land ahead of feature 1.
8. Data model across phases
This section re-slices the design doc's Data model and state section by phase, so each phase's spec-kit data-model.md can be cut with no overlap and no forward references. The design doc holds the fully-commented authoritative DDL and the entity-relationship diagram (its Figure 5.1); this section owns the ordering — which migration ships which table, which idempotency key and foreign-key action each carries, and where the DAO/Pydantic/RLS obligations land. Where a column here is condensed, the design doc's DDL is the source of truth; do not introduce divergent columns.
The design doc describes the analyzer schema as "one migration." The phased build splits it, because Phases 0-2 are entirely scoring-plane and the awareness-plane tables have no writer until Phase 3. Creating them early is harmless but pollutes each phase's data-model.md with entities that phase does not touch, so the migrations follow the phase boundaries.
Decision. The schema ships in three migrations, one per schema-changing phase.
session_analyzer_scoring_tables(Phase 0:analyzer_sessions,analysis_windows,analysis_segments);continuous_shadow_runs(Phase 1);session_analyzer_awareness_tables(Phase 3:session_digests,candidate_nudges,task_session_chat_messages,analyzer_outbox, plus the two flag columns). Phase 2 (Primary — report flip) adds no schema — it is a write-path change at the finalize seam plus apipeline_metadata.continuousJSONB stamp, not a DDL delta. All migrations go throughsupabase migration newand the standard migration-ledger discipline.
8.1 The master table: entity × phase
Every table below is keyed on (tasksession_id, task_id) because all analysis identity is per task (see Identity is per task at the end of this section). tasksession_id always carries an explicit ON DELETE CASCADE to task_sessions; task_id is always a plain UUID with no FK.
| Entity | Phase | Key columns | Idempotency key | FK / ON DELETE |
|---|---|---|---|---|
analyzer_sessions |
0 | state, lease_epoch, claimed_by, lease_expires_at, heartbeat_at, l1/l2/segment_next_index, config, context, cb_429_count |
UNIQUE (tasksession_id, task_id) |
tasksession_id→task_sessions CASCADE; task_id none |
analysis_segments |
0 | segment_index, status, chunk_range, map_result, segment_video_key, lease_epoch |
PK (tasksession_id, task_id, segment_index) |
tasksession_id→task_sessions CASCADE; task_id none |
analysis_windows |
0 (table); L1/L2 rows Phase 3 | level (1/2), session_prefix, window_index, status, coverage, result, carry_out, inputs_hash, lease_epoch |
UNIQUE (tasksession_id, task_id, level, session_prefix, window_index) |
tasksession_id→task_sessions CASCADE; task_id none |
continuous_shadow_runs |
1 | shadow_result, batch_result, parity, pipeline_metadata, status |
UNIQUE (tasksession_id, task_id) |
tasksession_id→task_sessions CASCADE; task_id none |
session_digests |
3 | digest, applied_l2, version, narrative_stale |
PK (tasksession_id, task_id) + version CAS |
tasksession_id→task_sessions CASCADE; task_id none |
candidate_nudges |
3 | evaluation_key, decision, question, signals, llm_reasoning, inputs_hash, delivered_at |
UNIQUE (tasksession_id, task_id, evaluation_key) |
tasksession_id→task_sessions CASCADE; task_id none |
task_session_chat_messages |
3 | role, content, nudge_id, client_msg_id, policy_action, evidence, context_meta |
UNIQUE (tasksession_id, client_msg_id) WHERE client_msg_id IS NOT NULL |
tasksession_id→task_sessions CASCADE; nudge_id→candidate_nudges SET NULL; task_id none |
analyzer_outbox |
3 | event_type, payload, claimed, claimed_by, retry_count |
conditional claim (UPDATE … WHERE NOT claimed) |
no FKs (transient queue) |
positions.nudges_enabled (col) |
3 | boolean NOT NULL DEFAULT false |
— | — |
task_sessions.nudges_active (col) |
3 | boolean NOT NULL DEFAULT false |
— | — |
Which shared mechanisms each table uses: analyzer_sessions carries the lease + fencing (session-scoped, all-or-nothing claim; lease_epoch fences zombie writes); session_digests uses CAS on version for the fold; analysis_windows/analysis_segments use the window-index/segment-index stamps for at-least-once → exactly-once. These are defined once in the shared-mechanisms primer; this section does not re-derive them.
8.2 Phase 0 — scoring-plane and lease tables
analyzer_sessions is the state machine and the lease. The lease/fencing fields (claimed_by, lease_epoch, lease_expires_at, heartbeat_at) are load-bearing from day one: the replay harness in the Build & Replay phase exercises lease-steal and stale-epoch fencing offline before any prod traffic.
CREATE TABLE analyzer_sessions (
analyzer_session_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tasksession_id uuid NOT NULL
REFERENCES task_sessions (tasksession_id) ON DELETE CASCADE,
task_id uuid NOT NULL, -- plain UUID, no FK
q_key text,
state text NOT NULL DEFAULT 'IDLE',
-- IDLE | LIVE | PAUSED | DRAINING | FINALIZING | DONE
-- | ABANDONED | FAILED_FALLBACK | BATCH_ONLY
active_session_prefix text,
last_chunk_num int,
last_chunk_at timestamptz,
l1_next_window int NOT NULL DEFAULT 0,
l2_next_window int NOT NULL DEFAULT 0,
segment_next_index int NOT NULL DEFAULT 0,
config jsonb NOT NULL, -- window-size snapshot, frozen at claim
context jsonb, -- task blob + position scopes, fetched once
claimed_by text,
lease_epoch bigint NOT NULL DEFAULT 0,
lease_expires_at timestamptz,
heartbeat_at timestamptz,
cb_429_count int NOT NULL DEFAULT 0, -- per-session rate-limit breaker
cb_window_start timestamptz,
drain_started_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tasksession_id, task_id)
);
Rows are per (session, task) because result_analysis entries are stamped with task_id and merged through upsert_task_analysis_entry (migration 20260522131458), and finalize_task_session converges on a distinct-task_id count (migration 20260526170000). The lease is session-scoped: the claim is all-or-nothing over the session's row set, and mid-session task rows are born already owned by the lease-holder. config freezes the window knobs (l1_window_s, l2_window_s, grace) at claim time so a mid-flight change cannot re-shard existing windows; context holds the once-fetched awareness inputs (populated only when the awareness plane is live).
analysis_segments is the scoring plane's ledger — one row per 20-min segment, map_result holding the verbatim run_v2_map_unit output the finalizer feeds to the unchanged v2 REDUCE.
CREATE TABLE analysis_segments (
tasksession_id uuid NOT NULL
REFERENCES task_sessions (tasksession_id) ON DELETE CASCADE,
task_id uuid NOT NULL,
segment_index int NOT NULL,
status text NOT NULL DEFAULT 'PENDING', -- PENDING | MAPPING | DONE | FAILED
chunk_range int4range,
start_seconds int, end_seconds int,
map_result jsonb, -- untouched run_v2_map_unit output, REDUCE input
segment_video_key text, -- pre-encoded playback MP4 for this segment
lease_epoch bigint, attempts int NOT NULL DEFAULT 0,
prompt_version text, token_usage jsonb, latency_ms int,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tasksession_id, task_id, segment_index)
);
analysis_windows (DDL in the design doc's Data model section) is created in the Phase 0 migration and carries the awareness plane's window idempotency and coverage discipline (the UNIQUE(…, level, session_prefix, window_index) key, the analysis_windows_open_idx partial index the sweeper scans, coverage, carry_out). But the L1 (1-min video) and L2 (5-min text) rows are only written once the awareness plane ships in Phase 3. Through Phases 0-2 the scoring plane derives its timeline from the CV activity-timeline pre-pass, not L1 windows — the scope decision recorded in the Build & Replay and Shadow phase sections.
Warning. The
window_indexisfloor((chunk_num − task_anchor) / 6)per(task_id, session_prefix), neverfloor(chunk_num / 6). The chunk counter is a page-global module static that does not reset on a task switch (chunkStorage.ts:303-306), so a raw divisor would start task 2's windows mid-stream and corrupt every arithmetic that treatswindow_indexas elapsed per-task footage (segment boundaries at 1200s, nudge lookback, the digest fold). Thetask_anchoris aMINover persisted rows, so a late or replayed event cannot shift it. This holds identically foranalysis_segments.
8.3 Phase 1 — the shadow table
Phase 1 (Shadow — prod, silent) adds exactly one table. It exists because the batch container's Gemini sentinel treats any non-placeholder result_analysis as "already done" — so the live path must never write result_analysis until parity is proven. The would-be report lands here and in S3 instead, alongside the batch result captured for the EQ-gate comparison.
CREATE TABLE continuous_shadow_runs (
shadow_run_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tasksession_id uuid NOT NULL
REFERENCES task_sessions (tasksession_id) ON DELETE CASCADE,
task_id uuid NOT NULL, -- plain UUID, no FK
client_submission_id text, -- ties this run to the batch DAG run
shadow_result jsonb NOT NULL, -- the result_analysis entry the live path WOULD write
pipeline_metadata jsonb NOT NULL, -- continuous stamp: model, prompt_version, latency_ms, cost_summary
batch_result jsonb, -- Fargate's actual entry, captured for parity (null until batch lands)
parity jsonb, -- computed EQ-gate metrics: score |Δ|, sub-dim agreement, citation resolution
segment_count int,
status text NOT NULL DEFAULT 'SHADOW_FINALIZED',
-- SHADOW_FINALIZED | BATCH_CAPTURED | COMPARED | COMPARE_FAILED
finalized_at timestamptz NOT NULL DEFAULT now(),
batch_captured_at timestamptz,
compared_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tasksession_id, task_id)
);
shadow_result is byte-shape-identical to what the live path would write to result_analysis, so the parity job diffs it directly against batch_result to feed EQ-1..6 (score median |Δ|, sub-dim agreement, recommended-flip rate, timeline recall, citation resolution, the T+10 SLO). pipeline_metadata is the same block Phase 2 later stamps into the report addendum. One shadow run per (session, task); the UNIQUE key makes finalize idempotent under at-least-once dispatch.
8.4 Phase 2 — no schema delta
The Primary (report flip) phase introduces no new table and no new column. The finalize seam begins writing the real result_analysis through the reused pipeline, and the run records a pipeline_metadata.continuous block — a JSONB value composed alongside the report, not a schema change. task_sessions.result_analysis stays byte-compatible (no new keys, no schema_version bump), so the phase's data-model.md is a delta of zero entities and documents only the write-path change and the fallback contract.
8.5 Phase 3 — awareness-plane tables and flag columns
The Awareness plane phase adds the four awareness tables and the two opt-in flags in one migration. DDL for session_digests, candidate_nudges, task_session_chat_messages, and analyzer_outbox is authoritative in the design doc's Data model section; the load-bearing constraints are: session_digests versioned with the invariant version = length(applied_l2) and a version = :expected AND NOT (applied_l2 @> :l2_id) CAS fold; candidate_nudges an audit ledger where every evaluation writes a row including suppressed ones, keyed UNIQUE(tasksession_id, task_id, evaluation_key) (evaluation_key = 'l2:{index}', one decision per L2 tick); task_session_chat_messages with task_id NOT NULL, a partial-unique client_msg_id (only candidate-sent rows carry one), and nudge_id → candidate_nudges ON DELETE SET NULL so the candidate-facing transcript outlives any purge of the internal ledger; analyzer_outbox a structural copy of the notifications outbox (fn_outbox_test_session_status, 20260320062639_notifications_service_tables.sql:77-104) with no FKs.
The flags are per-position opt-in, default off, stamped onto the session at creation:
ALTER TABLE positions ADD COLUMN nudges_enabled boolean NOT NULL DEFAULT false;
ALTER TABLE task_sessions ADD COLUMN nudges_active boolean NOT NULL DEFAULT false;
Decision.
nudges_activeis stamped frompositions.nudges_enabledat session creation, not read live frompositions— copying thepositions.scoring_versionrollout seam (added default'v1'in20260610000000, flipped to'v2'for new positions in20260621054203). A recruiter toggling the position mid-assessment must not change a running candidate's experience; the recruiter report badge needs the per-session truth so nudged and un-nudged cohorts never mix silently; and the analyzer resolves from its own session row rather than joiningpositionson every L2 tick.
8.6 Cross-phase conventions (stated once)
These apply to every table above, in whichever phase introduces it, and should be lifted verbatim into each phase's data-model.md assumptions.
Identity is per task. Sessions hold multiple tasks and the chunk counter is page-global, so every table keys on (tasksession_id, task_id): window and segment indexes are computed over that task's chunk sequence in per-task footage seconds (never global chunk_num arithmetic, never wall-clock); a task switch closes the open window early with a gap annotation; the digest, nudge engine, and chat all operate on the active task's fold and last-3 L2s; chat messages carry task_id NOT NULL. The session_prefix is minted per recorder mount (useShareScreenTusRecorder.ts:162) so prefixes never mix across tasks, and the batch pipeline's own discovery contract (task_analysis_runner/core.py:439-520) already stamps identity this way.
RLS-4 posture. Every new table gets the repo's standard service-role-only block — enable RLS, define no policies (PostgREST then denies anon/authenticated by default), revoke from anon and authenticated, grant to service_role — following the task_competencies (20260604084807) and notifications (20260320062639) precedents. No candidate or recruiter browser reads any table directly, task_session_chat_messages included: candidate delivery is Supabase Realtime broadcast on chat:{tasksession_id} (a push primitive with no backing-table read), and history/report reads go through authenticated Flask/Next.js routes with the service role. postgres_changes is deliberately avoided — it replays only rows the subscriber may SELECT, the permissive-policy trap report_comments fell into (20260606090000_report_comments_anon_access.sql:1-21).
Explicit FK ON DELETE. Every FK declares an action. tasksession_id → task_sessions is ON DELETE CASCADE on all six analysis tables plus the shadow table (analyzer rows are derived data with no meaning beyond their session; an erasure request must sweep them). task_id is a plain UUID with no FK — task_sessions holds its task list as JSONB tasks[].q_id (shared/models/_task_common.py:39-71), so there is no relational edge to a shared tasks table, and the batch pipeline already stamps q_id on the same no-FK basis. task_session_chat_messages.nudge_id is ON DELETE SET NULL; analyzer_outbox carries no FKs (a transient trigger-fed queue that must add no failure modes to a submit).
DAO + Pydantic obligation. The shared-treatment rule (one typed Pydantic model in shared/models/ + one DAO in shared/daos/, each the authoritative typed representation of its table — e.g. shared/models/task_session.py:40-98) applies only to tables with more than one consuming service. Only two of the analyzer tables qualify: candidate_nudges and task_session_chat_messages, both read by Flask at report time to compose the report addendum — they get shared/models/candidate_nudge.py + shared/daos/candidate_nudge_dao.py and shared/models/task_session_chat_message.py + shared/daos/task_session_chat_message_dao.py. The other six (analyzer_sessions, analysis_windows, analysis_segments, continuous_shadow_runs, session_digests, analyzer_outbox) have a single consumer — the analyzer — and live in the service-local repository layer under session_analyzer/db/, copying the notifications precedent. The flag columns need no new files: they ride the existing task_sessions and positions models, one column each.
9. Cross-cutting: flags, observability, rollback
This is the operational envelope that spans every phase. It is not itself a spec-kit feature; instead each phase's specify inherits this flag set, these continuous_* observability contracts, and this rollback posture as shared functional requirements — an "operational envelope" checklist the phase spec pulls in wholesale rather than re-deriving. Three rules hold across all of Phase 0 — Build & Replay through Phase 3 — Awareness plane:
- One author. Every recruiter-facing report is written by exactly one path (the service's finalize seam or the batch DAG), and the batch DAG is always reachable as the backstop. The report shape stays byte-compatible v2 the whole way (the design doc's Data model and state section, I-5).
- Report-safe by construction. Flipping any switch must leave a state that still produces a report for every submitted session — the batch pipeline absorbs whatever the service stops doing.
- Drilled once. Every switch is fire-drilled at least once in the phase that introduces it, before that phase can exit its soak week. A kill switch that has never been thrown is untested code on the worst possible day.
9.1 Feature flags and kill switches
The canonical inventory. All env flags resolve through one shared helper in shared/feature_flags.py, duplicated into the service's own config module the same way FF_FARGATE_TASK_ANALYSIS is mirrored into airflow/dags/utils/feature_flags.py (the design doc's Migration and rollout section, I-7); the service, like the Fargate container, does not ship shared/.
| Flag / switch | Scope | What it disables | Phase introduced | Fire-drill note |
|---|---|---|---|---|
FF_CONTINUOUS_L1 |
FastAPI TUS-hook fan-out + session_analyzer (mirrored env) |
Hook fan-out stops; no new windows ingested; resolve_analysis_trigger() returns batch (it requires L1 on) |
Phase 1 — Shadow | Dev Phase 1: flip off mid-session; in-flight sessions lose coverage → the 90% auto-fallback covers their finalize |
FF_CONTINUOUS_PRIMARY |
FastAPI submit endpoints (/complete, /submissions) |
Report authorship by the service; submit reverts to the exact current trigger_dag(dag_run_id=client_submission_id) call |
Phase 2 — Primary | Dev Phase 1 + prod early Phase 2: flip off, confirm the byte-for-byte current code path resumes |
positions.nudges_enabled |
DB column per position, NOT NULL DEFAULT false, stamped to task_sessions.nudges_active at creation |
Nudges + chat for new sessions of that position; already-stamped sessions keep their stamp (comparability) | Phase 3 — Awareness | Flip off, confirm new sessions stamp nudges_active=false and in-flight ones are unchanged |
FF_BAR_RAISER_NUDGES |
session_analyzer, global override |
All nudge generation + delivery everywhere, including in-flight stamped sessions (delivery check is runtime); report records nudges_active=true, nudges_suppressed_at so the record stays honest |
Phase 3 — Awareness | Global emergency kill; verify in-flight suppression + honest report stamp |
FF_CANDIDATE_CHAT |
Candidate app API routes + service chat endpoints, global | Chat writes rejected server-side; per the Awareness plane handling the panel switches to the neutral "your reviewer has stepped away" state (R-OPS) | Phase 3 — Awareness | Flip off, verify writes rejected and the neutral UX renders |
| Gemini 429 / spend circuit breaker | Automatic in service; per-session + global; CB_429_THRESHOLD=5 exhaustions in CB_429_WINDOW=10 min; per-session $2.50 cap + 80% alert |
L1/L2 launches pause with backoff, windows marked deferred; session → BATCH_ONLY; degradation ladder suspends nudges/chat → widens L1 → falls to batch |
Phase 1 — Shadow | Inject a 429 storm in Phase 0 replay and again in Phase 1 dev; verify the breaker trips, coverage drops, auto-fallback catches |
| Concurrency clamp | Env dial (max concurrent live sessions, set from the Phase 1 measurement) | Sessions beyond the clamp are simply not shadow/continuous-selected (deterministic hash ordering) → they run pure batch | Phase 1 — Shadow | Set the clamp low, verify overflow sessions run batch with 0% coverage and no report impact |
FF_FARGATE_TASK_ANALYSIS (existing) |
shared/feature_flags.py:24-33 (mirrored) |
Steers the batch fallback between the Fargate DAG and the legacy in-Airflow DAG | Pre-existing | Unchanged behavior; retained as fallback-of-the-fallback until Phase 4 deletes the legacy DAG |
Two dials sit alongside these but are ramp controls, not kill switches: CONTINUOUS_SHADOW_PCT / CONTINUOUS_SHADOW_POSITION_IDS (deterministic hash(tasksession_id) % 100 selection, so a session is shadow for its whole life) and CONTINUOUS_MIN_WINDOW_COVERAGE_PCT (default 90, the finalize-eligibility / auto-fallback threshold). The design doc's Migration and rollout section maps this table to switches K-1…K-9 with per-switch latency-to-effect.
Warning. The shadow flag must never let the service write
task_sessions.result_analysis. A non-placeholder entry trips the batch pipeline's Gemini sentinel (I-4) — the real batch run would then no-op and return the shadow entry verbatim, silently promoting unproven code. Phase 1 writes go tocontinuous_shadow_runs+ S3 only; a canary assertion in the service refuses to construct anupsert_task_analysis_entrycall while in shadow mode.
9.2 Observability
There is no metrics infrastructure today — all existing "metrics" are structured logs (Better Stack, operation + task_session_id fields) plus task_sessions.pipeline_metadata JSONB (I-10). The continuous system extends that precedent rather than inventing a store.
Structured logs. The service ships to Better Stack via the LOGTAIL_SOURCE_TOKEN_* pattern (runner.py:35-90 precedent), every record carrying operation + task_session_id + a per-stage latency_ms. The continuous_* operation namespace is the query surface:
| Operation | Fires when | Key fields |
|---|---|---|
continuous_l1_window_done |
An L1 window reaches a terminal state | window_index, coverage_pct, latency_ms, token_usage |
continuous_l2_coalesce |
A 5-min L2 + digest fold completes | l2_index, digest_version, latency_ms |
continuous_drain_start / continuous_shadow_finalize / continuous_finalize |
Drain barrier opens; shadow write (Phase 1); real finalize (Phase 2) | drain_seconds, coverage_pct, wall_clock_seconds |
continuous_coverage_gap / continuous_circuit_open / continuous_fallback |
Coverage below line; breaker trips; HANDOFF to batch | reason, largest_gap_min |
continuous_nudge_decision / continuous_chat_reply |
Bar Raiser decision (incl. SILENT); chat reply sent |
evaluation_key, decision, reply_latency_ms |
continuous_reconciler_fire |
Watchdog triggered a run for a missed finalize | dag_run_id, class (PARTIAL/DEGRADED) |
The shadow-comparison dashboard. A nightly Airflow DAG continuous_shadow_report (light, like the existing postmortem DAG) joins continuous_shadow_runs against task_sessions for sessions that reached ANALYSIS_DONE, computes the EQ-1…EQ-6 rolling aggregates (defined in the design doc's Migration and rollout section), uploads markdown to s3://{bucket}/dag_reports/continuous_shadow/{date}.md, and posts a Discord embed via the existing env-tagged webhook conventions. In Phase 2 the shadow is reversed — batch runs in shadow on a 10% sample for the first 4 weeks so the EQ metrics keep accumulating against the now-primary continuous output.
What each soak week watches (the exit signal for advancing):
| Phase | Soak | Primary watch | Advance when |
|---|---|---|---|
| Phase 0 — Build & Replay | Offline, repeatable passes | EQ-1…EQ-5 on ≥50-session corpus; fault-injection = zero silent degradation; two window configs prove config-driven sizing | Metrics reproduce within the measured noise floor |
| Phase 1 — Shadow | ≥3 weeks / ≥200 prod sessions | EQ-1…EQ-6 rolling incl. last-100 re-check; coverage distribution (≥95% above the 90% line); measured Gemini RPM/TPM per session; cost/session; zero shadow-attributable prod incidents (hook latency, quota, S3) | All EQ green + drain P95 ≤ 12 min in prod |
| Phase 2 — Primary | ≥4 weeks | Fallback rate (>5% investigate, >15% auto-page); reversed-shadow EQ still green; latency P50 ≤ 8 / P95 ≤ 12 min; reconciler firings; R-MP4 / R-PROCTOR on 100% | Fallback < 5% and no report ever later than the batch T+45 worst case |
| Phase 3 — Awareness | Dogfood → design partners → GA | Solution-leakage = 0 (a launch blocker, not a statistic); completion rate on nudged positions vs their own pre-nudge baseline; complaint rate ~0; nudge caps hit; chat reply p50 ≤ 8 s / p95 ≤ 15 s | ≥300 reviewed simulated nudges with zero leakage, clean design-partner window |
The "report missing" triage order extends the existing /track-task flow: session stamp / pipeline_metadata.analysis_source → service finalize-job state → reconciler log → DAG run (by client_submission_id) → Fargate/CloudWatch.
9.3 Rollback
Decision — fallback-by-construction. Submit always triggers the batch DAG with
dag_run_id=client_submission_id. The Gemini sentinel makes that trigger a ~1–2 min no-op when the live path already finalized, or a full batch run when it did not. The batch backstop is therefore obtained by not removing the existing submit→DAG trigger — so reverting any phase is a flag flip, never a code migration or data repair.
| Phase | Revert lever | What happens | Report author after revert |
|---|---|---|---|
| Phase 1 — Shadow | FF_CONTINUOUS_L1=false |
Hook fan-out stops; batch was always the sole author of every prod artifact | Batch (unchanged; zero recruiter impact) |
| Phase 2 — Primary | FF_CONTINUOUS_PRIMARY=false |
Submit endpoints revert to the exact current trigger_dag call; any in-flight service finalize completes harmlessly — its writes are the same writes batch would make and the finalize_task_session RPC arbitrates (last-writer-wins, already_finalized-safe) |
Batch, going forward |
| Phase 3 — Awareness | positions.nudges_enabled=false (new sessions) / FF_BAR_RAISER_NUDGES=false (global, in-flight) / FF_CANDIDATE_CHAT=false (chat surface) |
The awareness plane is additive and fail-soft; the scoring/report path is untouched | Unchanged — awareness plane is never on the report critical path |
The last-resort backstop for a submit that produced no report at all is the reconciler watchdog (10-min cadence, expiry-DAG precedent): it finds a COMPLETED session past 20 min with a submitted task lacking a non-placeholder result_analysis entry and no active DAG run, then triggers batch using the client_submission_id. A duplicate run is cost, never corruption — the single idempotency spine (client_submission_id) collapses FE retries, service job dedup, Airflow 409-dedup, and the reconciler into safe overwrites.
9.4 Reliability posture and deployment recap
The 429/reliability stance is summarized here; the mechanism detail lives in the design doc's Correctness: failure modes and invariants section.
Decision — pace, don't discover. The service paces its own Gemini call rate with a token bucket sized from the P3-measured RPM/TPM, on a separate GCP project +
GEMINI_API_KEY_CONTINUOUS(blast-radius isolation from the batch/proctor key). It never discovers the quota ceiling by taking 429s; the circuit breaker is the safety net, not the throttle.
- Derivable, not enqueued. Window readiness is recomputed from the ledger (
analysis_chunks+analysis_windows), never from an in-memory or durable queue that could drop a message. Any worker, after any restart, derives the same READY set. TUS post-finish hooks are fire-and-forget hints on a ≤250 ms budget (R-OPS); the 60 s S3 LIST sweeper over each instance's leased sessions is the guaranteed path — correctness never depends on a hint arriving. - Checkpoint = digest version. The resume pointer is
session_digests.version(monotonic, CAS-advanced,= len(applied_l2)). A restart rebuilds hot state from the last persisted window + digest; the lease +lease_epochfencing token keep zombie writes from a lease that was stolen mid-Gemini-call from ever landing. - Paced backfill. At reconciliation a PARTIAL session gets a bounded targeted backfill (≤
MAX_L1_BACKFILL_AT_FINALIZE=8windows) from the raw chunks — never deleted — before escalating; a DEGRADED session (coverage < 90%, gap > 5 min, orBATCH_ONLY) goes straight to whole-session batch. Backfill is bounded work, not an unbounded re-run.
Deployment recap. session_analyzer is a Coolify-deployed ARM64 service modeled on the notifications/ microservice, with its own GHCR image (ghcr.io/ngm9/) whose Dockerfile build-time-COPYs airflow/dags/video_analysis/ exactly like the Fargate runner — one pipeline, two runners, zero forks. The CI path filter watches airflow/dags/video_analysis/** for both images so the scoring logic can never drift between them. CI/CD follows the house pattern: main → dev (udev-1), release → prod. Placement is an open question the phase specs must close (a Coolify service on uprod-be-1 vs a dedicated box vs ECS), but at the 350-concurrent target it is a dedicated ARM host — one instance carries ~30–50 live sessions (work is ~85% LLM idle-wait), and horizontal scale is by lease-based session ownership with no sticky routing.
10. Spec-kit handoff
This is the seam where the plan becomes a build. Everything above is the pre-spec input; from here each phase runs the repo's standard spec-kit cycle — speckit specify → plan → tasks — producing its own specs/NNN-* directory. You do not specify the whole plan at once. You take one phase section, paste its scope, build list, data-model delta, and exit gate into a specify prompt, and let it emit that feature's spec.md, then plan.md and tasks.md. The phase sections were written for exactly this: read as narrative they are dense, but as raw material for a specify prompt they are complete.
One feature per shippable phase
The four phases do not map one-to-one to spec-kit features. Phases 0 and 1 are a single feature — the same service skeleton and scoring plane, first proven offline then run live in shadow — because they share one codebase and one data model and differ only in where the run happens. Phase 3 splits into two, because the nudge engine and the chat loop are independently flaggable, independently testable, and share only the ingest tables beneath them. The table below is the suggested slicing; confirm each name and number at build time.
| Phase | Suggested feature name(s) | One-line spec scope | Suggested P1/P2/P3 stories | Key entities | Main success criteria (= the phase exit gate) |
|---|---|---|---|---|---|
| 0 + 1 | continuous scoring service — shadow |
Session-analyzer service skeleton + scoring plane; proven offline on replayed prod sessions, then run live in prod writing only to a shadow table + S3 — never result_analysis. |
P1 live scoring writes a byte-shape-identical report to the shadow table; P1 lease + fencing gives one owner per session with crash-resume; P2 replay harness + fault injection proving parity offline; P3 per-stage cost/latency telemetry to Better Stack. | analyzer_sessions, analysis_windows (segment rows), analysis_segments, continuous_shadow_runs |
Soak-week shadow parity: score median |Δ|≤5, P95≤12; zero result_analysis writes; window coverage and continuous_* latencies within budget. |
| 2 | continuous scoring — primary + finalize seam |
The submit→/finalize→drain→tail-MAP→v2-REDUCE→finalize-RPC→sentinel-DAG seam; FF_CONTINUOUS_PRIMARY makes the service the report producer, Fargate → standby fallback. |
P1 report lands at T+4-9 min via the live path; P1 batch auto-fallback by construction when the service is down or ineligible; P2 reversed-shadow batch on 10% for continued parity; P2 stream-copy playback MP4 (prereq P5 lands here). | No new tables; pipeline_metadata.continuous stamp. |
T+10 report SLO P50≤8 min / P95≤12; fallback fire-drilled once; recommended-flip ≤5% on the reversed-shadow cohort. |
| 3 | bar-raiser nudge engine |
L1/L2/digest ingest + the 3-stage nudge engine (deterministic gate → Bar Raiser judgment → guardrail veto), per-position opt-in, decisions rendered as a recruiter-report addendum. | P1 gate + judgment + guardrail with every decision (incl. suppressed) audited; P1 per-position opt-in (positions.nudges_enabled) + addendum panel; P2 CAS digest fold; P3 cost degradation ladder. |
session_digests, candidate_nudges, analyzer_outbox, L1/L2 rows in analysis_windows; columns positions.nudges_enabled, task_sessions.nudges_active |
≥300 human-reviewed simulated nudges with zero solution leakage before the first real candidate; nudge visible ~30-110 s after the closing 5-min boundary. |
| 3 | candidate chat |
Candidate ↔ Bar Raiser chat: authenticated Next.js route → service-role insert → outbox → analyzer reply → Supabase Realtime broadcast → candidate panel. | P1 candidate asks, reply p50≤8 s / p95≤15 s; P1 solution-seeking deflected per the answer policy; P2 prompt-injection defense + rate caps; P2 chat answers from digest when analysis lags. | task_session_chat_messages, shared analyzer_outbox; candidate-app API route + chat panel |
Reply SLO met under soak; zero solution leakage over the reviewed corpus; tasks.solutions structurally absent from every chat context. |
Decision. Each phase's exit gate becomes that feature's Measurable Success Criteria verbatim. The gates in the phase sections and in Cross-cutting: flags, observability, rollback are written as testable thresholds (EQ-1..6, the T+10 SLO, the ≥300-nudge review bar) precisely so a specify prompt can lift them into
spec.md's Success Criteria section without softening them into prose.
Repo convention and numbering
Backend and service specs live in Utkrushta/specs/NNN-*; the service itself follows the 001-notifications-service precedent (Coolify microservice, outbox trigger pattern). The spec.md skeleton is fixed by convention — User Scenarios with a P1/P2/P3 story split, mandatory Functional Requirements (FR-NNN), Key Entities, Measurable Success Criteria, Assumptions — as seen in Utkrushta/specs/021-task-scoring-v2/spec.md. The highest number present in Utkrushta/specs/ is 021, so the next free numbers to confirm are 022, 023, 024, 025 for the four features above, in phase order.
The two Phase-3 candidate/recruiter surfaces are separate frontend companion specs in their own repos: the recruiter "Bar Raiser transcript" addendum panel in recruiter-utkrusht/specs/ (next free 021; highest present is 020) and the candidate chat panel in utkrushta-assessment/specs/ (currently empty → 001). Keep them thin — they render entities the service already owns, per R-SHAPE.
The prerequisite workstreams (P1-P5) in the Prerequisite PRs section are not phase features. P2 (pricing fix), P4 (private trace ACL), and P5 (stream-copy spike) are plain PRs with no spec. P1 (wire position skill/proficiency scopes into awareness context) and P3 (separate GCP project + measured quota) are large enough to warrant a one-page spec each if the builder prefers, but neither blocks on the spec-kit ceremony. All five land in parallel and gate the phases named there.
Definition of done for this planning document
This doc is done when every phase can start specify cold — no back-reference to a design decision that was never made. Concretely: each phase section carries a scope stated as operator or user value, a concrete build list (modules, tables, endpoints, prompts, CI paths) that seeds FRs, a data-model delta naming its Key Entities, a measurable exit gate that becomes Success Criteria, and an explicit spec boundary — what belongs to this feature and what is pushed to a later one. The Data model across phases section pins which phase introduces which table so no two specs claim the same DDL; the Shared mechanisms primer lets a spec cite CAS, lease + fencing, and one-pipeline-two-runners by name instead of re-deriving them; and this section fixes how many features each phase becomes and where the boundaries sit. With those in hand a builder can open the phase, run speckit specify, and get a spec.md that needs clarification but not archaeology.