UtkrushtDesign Document

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_analysis entry 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 write result_analysis; shadow output goes to continuous_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.

CONTINUOUS ANALYSIS — ROADMAP Phase 0 · Build & Replay offline, dev · scoring plane risk: none Phase 1 · Shadow prod, silent · live scoring risk: zero Phase 2 · Primary report flip · T+4-9 min risk: contained Phase 3 · Awareness nudges + chat · opt-in candidate-visible replay EQ parity proven fallback trusted offline passes soak ≥3 wk soak ≥4-6 wk dogfood → partners Fargate batch pipeline — standby / fallback / sentinel-no-op hop (never deleted) report safe report flip (contained) legacy on standby
Figure 1.1 — The scoring plane graduates offline → shadow → primary (Phases 0-2) while the report stays safe; the awareness plane lands only after (Phase 3); Fargate runs underneath the whole way as the fallback.

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.

ONE PIPELINE, TWO RUNNERS video_analysis/ single source, zero forks session-analyzer image COPY at build Fargate image COPY at build PRIMARY runner (live) segments + v2 REDUCE STANDBY runner (batch) sentinel no-op / fallback green = new / primary grey = legacy / standby
Figure 2.1 — One 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.yaml and its new session-analyzer twin) must both include airflow/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.config at session start and frozen for the session's life. Retuning l1_window_s/l2_window_s mid-session would re-shard existing windows and break window_index idempotency (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_windows arrive 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.pyPhase 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_reducecombine_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
PHASE 0 REPLAY LOOP (DEV) Prod S3 chunks read-only, IAM Replayer accelerated events SESSION-ANALYZER (DEV) Ingest + 60s sweeper Segment assembler Segment MAP CV timeline Tail MAP + REDUCE Fault injection drops / 429 / restart Report object v2 Appendix-A shape Batch v2 baseline stored result_analysis Comparator EQ-1/2/3/5/6 + floor EQ report S3 + Discord
Figure 4.1 — The Phase 0 loop: prod chunks (grey, read-only) are replayed at speed through the dev analyzer; the report object is scored against the stored batch baseline (grey) by the comparator. Fault injection (amber) perturbs the replayer.

(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):

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:

(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:

(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

(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_analysis in 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_id count could flip ANALYSIS_DONE before batch ran. Enforced by a canary assertion (design risk R-1): the service refuses to construct an upsert_task_analysis_entry / finalize_task_session call while in shadow mode. Shadow writes go to continuous_shadow_runs + S3 only.

PHASE 1 — SHADOW (PROD, SILENT) NEW — SHADOW PATH Hook fan-out (hint) less than 250ms + S3 sweep Session-analyzer (leased) live segment MAP + tail + REDUCE continuous_shadow_runs + traces/continuous (private S3) EXISTING — BATCH PATH (UNCHANGED) submit to Fargate DAG result_analysis to report S3 chunk stream — screensharesession_/{i}_taskid/{n}.webm (source of truth) NEVER (sentinel hazard)
Figure 5.1 — Shadow runs the full live scoring path into its own store; batch keeps owning the report; the analyzer-to-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, scorePhase 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)

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

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):

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

5j. Failure modes handled + kill switches

Every kill switch leaves a fully working system, because batch is authoring every report regardless of what shadow does.

5k. Dependencies

5l. Assumptions

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_analysis stays byte-identical (R-SHAPE): no new keys, no schema_version bump.

Decision. There is no separate fallback DAG. Submit always triggers the same tasksession_completion_fargate DAG with dag_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.

THE FINALIZE SEAM Submit endpoint mints client_submission_id resolve_analysis_trigger FF_CONTINUOUS_PRIMARY service POST /finalize 5s ack · async drain eligible & coverage ≥90%? else HANDOFF drain ≤120s + tail MAP v2 REDUCE (library reuse) finalize_task_session RPC ANALYSIS_DONE · T+4-9 min trigger SAME Fargate DAG dag_run_id = submission_id Gemini sentinel · no-op ~1-2 min, no Gemini cert / notify / recommend runs once trigger_dag (batch) same submission_id Fargate full analysis writes result_analysis yes off no · HANDOFF full batch writes report live continuous path batch fallback handoff / degraded
Figure 6.1 — One submit, one Fargate DAG run keyed by 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:

(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-1698 runs after it), so on a no-op hop the batch container will not build the combined MP4 — the service must produce screenshare_video itself via incremental -c copy concat (the same mechanism the proctor path uses), finalized at drain. Proctor concat has no sentinel, so proctor_video is re-built by the hop for free — verify it in shadow.

(i) What you watch during the soak week

(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

(l) Assumptions

  1. Phase 1 shadow proved the continuous report equivalent to batch (scores equivalent by construction, same pinned prompt 2.6.0 and calculator).
  2. The Gemini sentinel behaves as documented (core.py:1626-1641): any non-placeholder result_analysis entry → the batch container skips chunk download, ffmpeg, and Gemini and exits in ~1-2 min.
  3. client_submission_id is reliably minted and passed by FE (usePerTaskSubmit.ts:18-29); the reconciler absorbs the best-effort task_submissions write gap.
  4. Fargate cap=1 is acceptable because the report is already live — ANALYSIS_DONE is flipped by the service and the notifications microservice fires on the Realtime status change independently of Airflow; only cert/postmortem/recommended lag, and that is minutes-tolerant.
  5. 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 + a pipeline_metadata.continuous block alongside result_analysis, not inside it. No new result_analysis keys, no schema_version bump, 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

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

BAR RAISER — 3-STAGE PIPELINE Trigger L2 close · chat Deterministic gate caps · 2-of-3 struggle Bar Raiser Flash judgment turn Fail-closed guardrail lint + veto turn Outbox → broadcast chat:{id} → dock NO_ACTION (gate) NO_ACTION (declined) DROP (audited) proceed no action (logged) vetoed / dropped
Figure 7.1 — The nudge pipeline can only make the engine more conservative: a failed gate skips the LLM entirely, the judgment may decline, and the fail-closed guardrail drops anything unsafe.

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

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

7l. Assumptions

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:

  1. 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.
  2. Bar Raiser nudge engine. candidate_nudges, the gate → judgment → guardrail pipeline, persona prompt 1.0.0, and the nudge half of the report addendum. Owns the ≥300-simulated-nudge gate.
  3. 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 a pipeline_metadata.continuous JSONB stamp, not a DDL delta. All migrations go through supabase migration new and 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_idtask_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_idtask_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_idtask_sessions CASCADE; task_id none
continuous_shadow_runs 1 shadow_result, batch_result, parity, pipeline_metadata, status UNIQUE (tasksession_id, task_id) tasksession_idtask_sessions CASCADE; task_id none
session_digests 3 digest, applied_l2, version, narrative_stale PK (tasksession_id, task_id) + version CAS tasksession_idtask_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_idtask_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_idtask_sessions CASCADE; nudge_idcandidate_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_index is floor((chunk_num − task_anchor) / 6) per (task_id, session_prefix), never floor(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 treats window_index as elapsed per-task footage (segment boundaries at 1200s, nudge lookback, the digest fold). The task_anchor is a MIN over persisted rows, so a late or replayed event cannot shift it. This holds identically for analysis_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_active is stamped from positions.nudges_enabled at session creation, not read live from positions — copying the positions.scoring_version rollout seam (added default 'v1' in 20260610000000, flipped to 'v2' for new positions in 20260621054203). 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 joining positions on 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 FKtask_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:

  1. 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).
  2. 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.
  3. 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 to continuous_shadow_runs + S3 only; a canary assertion in the service refuses to construct an upsert_task_analysis_entry call 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.

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 specifyplantasks — 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.


Utkrusht — Meaningful Skill Assessments for India