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.

Live interactive version: https://www.html-docs.com/d/91377aac-6226-4b4e-9fec-bdf752ca235a?token=d2bb115b



Purpose and how to use this document

This is the phased implementation plan for continuous hierarchical video analysis — the how, in what order, behind which gates. The design doc (Utkrushta/docs/continuous-hierarchical-analysis.md) owns the why (architecture, window math, Bar Raiser, cost model, decisions D1-D14). On sequencing or scope this plan wins; for mechanism detail, defer to the design doc.

Its real job is to feed spec-kit: each phase section is a pre-spec seed — scope as user stories (P1/P2/P3), build list, seams touched, data-model delta, exit gate, out-of-scope — enough to run speckit specify → plan → tasks.

The cadence

Every phase moves on one rhythm: build → deploy → soak a week → observe → continue. Ship the code, put it in front of real or replayed traffic, watch the soak dashboards for a week, then start the next phase. These mechanisms are new to the team, so people operate them before more weight stacks on top.

Decision. The recruiter report (task_sessions.result_analysis) is the crown jewel — never bet on unproven code. Scoring moves to the new service through shadow → primary over two phases, not a deploy-day flip. The service writes only to a shadow table until a soak week proves byte-shape parity against the batch pipeline; only then does it earn the real column, with Fargate demoted to a still-trusted fallback.

The Phase 0-2 win is latency, not accuracy: the same v2 pipeline runs live, so scores match by construction — the report lands at T+4-9 min, not T+30-45. Accuracy and candidate-facing features arrive only in Phase 3.

How to read this document

Read in order; each section assumes the ones before.

Section What it gives you
Phasing overview Four-phase map; scoring/awareness plane split; report risk none → contained.
Shared mechanisms primer Define-once: CAS, lease + fencing, one-pipeline-two-runners, three stamps, digest.
Prerequisite PRs The five workstreams (P1-P5) that land in parallel and gate phases.
Phase 0 — Build & Replay Service skeleton + scoring plane, proven offline on recorded prod sessions.
Phase 1 — Shadow Service live in prod, shadow table only; Fargate still owns the report.
Phase 2 — Primary Parity proven → service produces the report; the T+9 latency win lands.
Phase 3 — Awareness plane L1/L2/digest + Bar Raiser nudges + chat, per-position opt-in; only candidate-facing phase.
Data model across phases Every new table and column, tagged by introducing phase.
Cross-cutting Flags, BetterStack ops names, kill switches, fire-drills across phases.
Spec-kit handoff How to slice each phase into features; next numbers; sequencing for a team of 1-2.

1. Phasing overview

TL;DR. We move scoring off the batch Fargate container into a live Coolify service, so the recruiter report (task_sessions.result_analysis) lands at T+4-9 min instead of T+30-45. Ship in four phases, each on a build → deploy → soak-a-week → observe cadence:

The win is latency, not accuracy. The fallback: Fargate is never deleted — it stays on standby, and a Gemini sentinel makes each batch run a no-op when the live path already finalized, or a full rerun when it didn't.

Two planes drive the split. The scoring plane (Phases 0-2) is today's v2 pipeline run live, reusing airflow/dags/video_analysis verbatim (one pipeline, two runners). The awareness plane (Phase 3) is all new, fail-soft, and additive — it never enters the report (R-SHAPE). They share no runtime state, so scoring can go fully primary before a single nudge exists.

The four phases

# Phase Report risk Exit gate
0 Build & Replay (offline) none (offline) EQ-1..5 green on ≥50 paired sessions; fault-injection clean; replay reproducible within noise floor.
1 Shadow (prod, silent) zero (result_analysis untouched) ≥200 sessions over ≥3 wk; EQ-1..6 green; drain-to-report P95 ≤12 min; Gemini TPM/RPM measured.
2 Primary (report flip) contained (fallback trusted) ≥4-6 wk primary; fallback <5% (→<2%); R-MP4 + R-PROCTOR hold; reconciler fire-drilled.
3 Awareness plane n/a (additive, flag-gated) zero leakage across ≥300 reviewed nudges; dogfood + partners clean; recruiter badge shipped.

Two decided framings

Decision. The Phase 1/2 win is latency, not accuracy. Phases 0-2 run the same v2 MAP + REDUCE over the same activity timeline as batch, so scores match by construction; only when the report lands changes (T+4-9 min vs T+30-45). Any accuracy change (finer L1 timeline, digest carry-chain) arrives only in Phase 3, and never enters scoring prompts (R-AWARE-SCORING). Sell "same report, sooner," not a quality upgrade.

Decision. Shadow exists because of the Gemini-sentinel hazard. The batch container treats any non-placeholder result_analysis as "already done" and no-ops, skipping chunk download, ffmpeg, and Gemini (core.py:1626-1641). That is what makes Fargate a free fallback — but it cuts both ways: a wrong or partial live write would silently promote broken code to primary. So in Phase 1 the service never writes result_analysis; shadow output goes to continuous_shadow_runs + S3 only (R-IDLE). Phase 2 lets it write the real column only after shadow proves parity.

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.

Fargate stays registered on standby via FF_FARGATE_TASK_ANALYSIS (shared/feature_flags.py:24-33); Phase 2 re-triggers the same DAG (dag_run_id = client_submission_id) so post-steps (cert, notification, recommended) run once, and a failed live write falls through to a full batch run. The demotion is operational, not a code deletion.

Spec-kit slicing. Four features: scoring service — shadow (Phase 0+1, one codebase), scoring — primary + finalize seam (Phase 2), bar-raiser nudge engine and candidate chat (Phase 3). Prereqs P1-P5 land in parallel and gate phases (P4→0, P2/P3→1, P5→2 playback, P1→3). Near-serial spine — each exit gate is the next entry gate — realistic for a team of 1-2.


2. Shared mechanisms primer

Six mechanisms carry every phase. Defined once here; phase sections use them by name. Full rationale is in the design doc.

Mechanism What it is Where it's used
CAS (compare-and-swap) Conditional UPDATE guarded by an expected version; 0 rows updated = retry. No lock held across a Gemini call. Default write discipline from Phase 0 on: digest fold, window/segment writes, every status transition.
Lease + fencing Time-bounded session ownership on analyzer_sessions (claimed_by, lease_expires_at, lease_epoch). One owner; reclaimable on expiry; zombie writes fenced by epoch mismatch. Phase 0 (survive restarts) and the Phase 3 horizontal-scale primitive — shard sessions by claim, no coordinator.
One pipeline, two runners airflow/dags/video_analysis/ is the only scoring source; the Fargate and session-analyzer images both build-time-COPY it. Only the runner differs (batch vs live). Makes "the batch pipeline, live" true in Phases 1-2: identical code, so scores match by construction.
Three stamps window_index (derived, idempotent), session_digests.version (CAS-advanced resume pointer, = len(applied_l2)), lease_epoch (fencing counter). Coordinate every write. window_index is recomputed from S3 keys, never a stored counter a crash could corrupt.
Digest vs L2 L2 = immutable 5-min window row (analysis_windows, level=2). Digest = one capped-rewrite row per task (session_digests), the running whole-session accumulation. One combined Flash call at each 5-min boundary emits both. Phase 3. Digest is the Bar Raiser's whole-session memory; the last-3 L2s are its recent-15-min view.
Identity per (tasksession_id, task_id) Windows, segments, digest, nudges, chat all carry task_id; indexes run over each task's own footage, not global chunk/wall-clock (chunk counter is page-global chunkStorage.ts:303-306; forceCut blocks straddle useShareScreenTusRecorder.ts:768-821). Every phase. Report convergence is per-task today (finalize_task_session distinct-task_id; migration 20260526170000).

2.1 CAS and the lease

CAS is a conditional UPDATE guarded by an expected version — 0 rows updated means someone else moved first, so re-read and retry. Its canonical use is the running-digest fold (never let two folds win, never apply the same L2 twice):

UPDATE session_digests
   SET version = version + 1, digest = :folded, applied_l2 = applied_l2 || :l2_id
 WHERE tasksession_id = :ts AND task_id = :tid
   AND version = :expected        -- guard: nobody folded since we read
   AND NOT applied_l2 @> :l2_id;   -- never apply the same L2 twice
-- 0 rows ⇒ re-read, re-fold, retry

The lease gives one owner per session. Claim/steal and heartbeat are both CAS; every downstream write carries the lease_epoch it claimed under, so a stalled worker's late write lands 0 rows. On restart the lease expires within its 30 s TTL and any instance re-claims — deploys are drain-less.

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 holds it

Decision. Ownership is a fenced lease row, not a Postgres advisory lock — Supabase's pooled access has no stable session to hold a lock on, and a lease row is inspectable and hands us an explicit fencing token (failure-modes §6).

2.2 One pipeline, two runners

Scoring logic lives in one import-clean directory, airflow/dags/video_analysis/. The Fargate image and the new session-analyzer image both COPY it at build — zero forks, only the runner differs. Phases 0-2 make the live path primary; the finalize seam still triggers Fargate on every submit, where its Gemini sentinel no-ops if result_analysis already exists (core.py:1626-1641) or runs the full batch fallback if it didn't.

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.

Decision. Both CI path filters (.github/workflows/task-analysis-image.yaml and its new session-analyzer twin) must include airflow/dags/video_analysis/**. If they drift, one runner ships a stale pipeline — the most dangerous maintainability failure in this plan.

2.3 Stamps, digest, identity

The three stamps, digest-vs-L2, and per-task identity are captured in the table above. One frozen-config hazard is worth stating outright:

Warning. Window sizes freeze into analyzer_sessions.config at session start. Retuning l1_window_s/l2_window_s mid-session re-shards existing windows and breaks window_index idempotency (invariant I1); new sizes apply to new sessions only (failure-modes §4.7).


3. Prerequisite PRs (P1-P5)

Five small, independent fixes (the D14 prereqs). They land in parallel with — mostly before — Phase 0. None needs the session-analyzer service to exist, so all five can start now. P3 is the one hard blocker.

Warning. P3 is the only load-bearing blocker. Phase 1 shadow roughly doubles Gemini load on an unmeasured quota, using a key shared with the proctor pipeline — a shadow 429-storm could starve the real report and proctoring at once (I-8; followup-6.md §1-2). No shadow session runs until the separate GCP project, GEMINI_API_KEY_CONTINUOUS, and a recorded quota table all exist.

3.1 The five workstreams

PR What Gates Blocking?
P1 Resolve position skill scopes (positions.competencies → competencies.scope/long_scope + weightage) into awareness-plane prompt context; {skills_to_be_checked} is unsubstituted today Awareness plane (Phase 3) No
P2 Fix GEMINI_FLASH_PRICING for gemini-3-flash-preview, price generate_tags on the Flash table, fold all Gemini tokens into cost_summary Shadow cost metrics (Phase 1) No
P3 Record actual Gemini RPM/TPM/RPD from AI Studio; stand up a separate GCP project + GEMINI_API_KEY_CONTINUOUS[_DEV] Phase 0 replay + shadow & pacing (Phase 1) Yes
P4 Private ACL on all traces/continuous/* artifacts; never the public-read chunk ACL Phase 0 No
P5 Stream-copy (-c copy) concat for the screenshare_video MP4 Phase 2 playback (R-MP4) No

Decision. P1 injects skill scopes into awareness-plane prompts only (nudge + chat), never a scoring MAP/REDUCE prompt — the scoring plane stays byte-identical to batch so the score-parity claim holds (R-AWARE-SCORING).

Spec-kit: P1 and P5 are small standalone features; P2/P4 are plain PRs; P3 is an ops + env PR. Sequencing: land all five in parallel, close before Phase 0 sign-off.


4. Phase 0 — Build & Replay (offline)

At a glance

Goal Build the session_analyzer service + scoring plane; prove it offline against recorded prod sessions where being wrong is free.
Ships Dev-deployable container + a permanent replay/regression harness. No prod row, prod Gemini key, or recruiter report touched.
Exit gate Replayed prod sessions reproduce their stored batch v2 scores within the measured noise floor (G-1…G-7).
Depends on P3, P4, S3 chunk-retention check (all blocking); P2, P5 soft. First phase — no prior phase.

Decision. Phase 0 runs the scoring plane only — 20-min segment MAP + REDUCE + finalize-as-library over the existing CV activity-timeline pre-pass, exactly as batch runs it. L1/L2/digest, nudges, chat, and the EQ-4 timeline-recall gate are validated separately in Phase 3. Nothing algorithmic changes, so continuous-vs-batch deltas should collapse to the temperature-0.3 replicate floor.

Out of scope (→ owner)

In-scope = the build list. Deliberately left out:

Out Owner
TUS hook fan-out; continuous_shadow_runs writes on live sessions Phase 1
Real result_analysis write, finalize_task_session, sentinel hop; recruiter master MP4, screenshare_video Phase 2
L1/L2/digest workers + analysis_windows L1/L2 rows; nudge engine, chat, EQ-4 recall, nudge corpus Phase 3
Prod deploy, prod Gemini key, capacity ramp Phase 1/2

Build list (→ FRs)

All new unless marked Reuse.

Item Kind Responsibility
config.py module Env knobs (segment/grace/drain/lease); snapshot into analyzer_sessions.config at claim
main.py module FastAPI app + asyncio lifespan; start loops (notifications/main.py pattern)
routers/health.py endpoint GET /hello, /status (loop states, lease gauges, queue depth)
routers/hooks.py endpoint POST /v1/chunk-events bearer receiver (replayer-fed in P0)
core/session_registry.py module analyzer_sessions state machine + lease claim/heartbeat/fencing
core/sweeper.py module 60s S3 LIST reconcile + stale-lease reclaim + watermark/grace timers
core/segment_assembler.py module Chunks → 1200s stream-copy (-c copy) unit + one ffprobe check
core/segment_worker.py module run_v2_map_unit per 1200s per-task footage over the CV timeline. Reuse video_analysis.pipeline
core/finalizer.py module Drain → tail MAP → run_v2_reducecombine_v2_buckets → report object. Reuse synthesis/result_combiner
db/*.py, logging_setup.py module Service-role DAOs + models for both tables; Better Stack structured fields (operation, task_session_id)
analyzer_sessions, analysis_segments tables Lease/fencing/state; scoring-plane 20-min MAP units (see data model)
session_analyzer.Dockerfile + session-analyzer-dev.yaml CI COPY video_analysis verbatim; build → GHCR → Coolify dev; path filter on airflow/dags/video_analysis/**
scripts/replay/* modules Harness: corpus_builder (≥50 stratified + baseline), replayer (accelerated events), fault_injector, comparator (EQ + noise floor → dag_reports/)
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 (test) 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 — Fault injection (amber) is a test technique: we deliberately break the replay — dropped chunks, 429 storms, restarts — to prove the analyzer recovers before it ever runs in prod. Prod chunks (grey, read-only) are replayed at speed through the dev analyzer; its report object is scored against the stored batch baseline (grey) by the comparator.

Seams touched (import-only, not edits — the two-runners rule)

Seam Where P0 use
run_v2_map_unit, _build_v2_system_instruction, floor helpers pipeline.py:833-1103, 177-222 imported 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 the comparable report object
CV timeline pre-pass + get_timeline_slice pipeline.py:360-444, timeline_builder.py:274-329 scoring timeline source (P0-2, unchanged)
gemini_client, calculator_v2 + config_v2, SEGMENT_DURATION video_analysis/ deterministic math + retries
TUS post-finish hook tus.py:152-274 identified, not edited — fan-out lands Phase 1
Gemini sentinel task_analysis_runner/core.py:1626-1641 not edited — why P0 can never write result_analysis
Dev Supabase ctzweurujanstonppfyh + dev bucket; prod S3 via scoped IAM infra writes dev-only; prod reads read-only

Data-model delta (→ Key Entities)

Shared mechanisms in play

Exit gate — Measurable Success Criteria

Noise floor measured first (20 sessions × 2 batch runs at temperature 0.3) governs the relative thresholds. All must hold on the ≥50-session corpus before Phase 1.

# 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 ≥ 55% (or ≥ floor); NA-confusion ≤ 5%; within-session Spearman ρ median ≥ 0.80; no sub-dim mean signed Δ > 0.75
G-3 EQ-3 recruiter-decision Kendall τ ≥ 0.85 per position (≥ 5 sessions); recommended-flip ≤ 5% and every flip human-reviewed vs video; cert-threshold flips same
G-4 EQ-5 grounding citations ≥ 98% evidence block_ids resolve; 100% derived timestamps sane; ≥ 90% human-verified accuracy (sample 200)
G-5 EQ-6 operational envelope per-stage wall-clock consistent with T+4-9 (tail MAP 443-506s/segment, REDUCE 30-90s); coverage ≥ 90% avg; cost/session ≈ batch ($1.10 avg, $3.25 max, scoring-only); 0 sustained 429
G-6 Fault-injection suite zero silent-degradation failures; coverage accurate to ±1 window per scenario
G-7 Replay repeatability same corpus + config + prompt 2.6.0 reproduces every metric within the noise floor

Failure modes & kill switches

Injected fault Mode Expected behavior
Dropped / never-arriving chunk F1.2 analyze-with-gaps; gap manifest; honest coverage math
Out-of-order arrival F1.3 assemble 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 → BATCH_ONLY; deferred segments; Discord alert (dev)
Coverage < 90% K-5 offline HANDOFF analogue — session degraded, not fast-path finalized
Mid-session restart F4.1 / F4.2 lease expires, re-claim, resume from last segment; fencing rejects the zombie write

No kill switch is live in prod in P0; the circuit breaker (K-7), coverage-fallback (K-5), and lease-fencing are fire-drilled offline so they enter Phase 1 as tested code.

Observability, dependencies & assumptions

Watch (no prod soak; repeated replay passes accumulate the corpus + fault matrix):

Dependencies:

Dep Why it gates P0
P3 — separate GCP project + GEMINI_API_KEY_CONTINUOUS_DEV, measured RPM/TPM Blocking — replay must not starve the shared batch/proctor key; yields the first continuous RPM/TPM profile
P4 — private ACL on trace/window artifacts Blocking — harness artifacts must not inherit the public-read chunk ACL
S3 chunk-lifecycle check Blocking — corpus needs historical chunks un-deleted; pin to a replay-corpus/ prefix if a rule exists
P2, P5 (soft) Trustworthy P0 cost (G-5); -c copy viability. P1 gates Phase 3, not P0.

Assumptions:

Spec-kit slicing

One spec-kit feature — "continuous scoring service — offline build & replay" — since the replay harness is the acceptance vehicle and belongs in the same spec.md. FRs cleave near-serially: (1) skeleton + ingest + lease/state → (2) scoring-plane reuse + assembler + finalizer → (3) harness + fault injector + comparator; the corpus builder runs parallel from day 1. Story priority: P1 = replay→report + comparator + fault suite; P2 = continuous_* logging + identity-from-(session_ts, chunk_num); P3 = repeatable regression suite. Key Entities = analyzer_sessions + analysis_segments; Success Criteria = G-1…G-7. A team may instead fold Phase 0+1 into one "shadow" feature — the boundary sits where output stops going to harness artifacts and starts going to continuous_shadow_runs on live traffic. Take the next free specs/NNN- at build time.


5. Phase 1 — Shadow (prod, silent)

At a glance
Goal Run the scoring plane live in prod against real sessions; prove it matches batch, at zero report risk.
Ships Shadow store (continuous_shadow_runs + private S3), live scoring, rate limiter, isolated Gemini key, EQ dashboards. No candidate/recruiter change.
Exit gate ≥ 1 week AND ≥ 200 shadow sessions, EQ-1…EQ-6 pass, clean reliability record, zero result_analysis writes.
Depends on Phase 0 green; P3 (blocking), P2, P4.

The win is confidence, not latency — "the batch pipeline, live." Same CV activity-timeline pre-pass as batch (not L1 windows), so scores match by construction; the finer L1 timeline is Phase 3. The soak proves lease/fencing, 429 pacing, drain, and per-task segment identity in prod before Phase 2 bets the report on them.

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 batch run then skips download, ffmpeg, and Gemini and returns the shadow entry verbatim — the unproven pipeline silently becomes primary, and the finalize RPC could flip ANALYSIS_DONE before batch ran. Enforced by a canary assertion (R-1): the service refuses to construct an upsert_task_analysis_entry / finalize_task_session call 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. Scope and user stories

Deliverable: a parity dataset (≥ 200 real sessions scored twice, batch and shadow) plus a clean operational record — the entry evidence for the Phase 2 flip.

In scope (Phase 1) Out (owner)
TUS hook fan-out (fire-and-forget hint) Submit → /finalize seam, report flip → P2
continuous_shadow_runs + writer + private-S3 dump Writing result_analysis / pipeline_metadata / scoreP2
Live scoring: 20-min segment MAP + tail MAP + v2 REDUCE Fargate demotion / sentinel no-op → P2
Token-bucket rate limiter + 429 breaker Coverage-gated auto-fallback (K-5) → P2
Isolated Gemini key (GEMINI_API_KEY_CONTINUOUS) L1/L2/digest, nudges, chat → P3
Deterministic selection + ramp (allowlist → 5% → 25% → 100%) Skill/proficiency scopes in prompts (P1) → P3
Nightly EQ dashboards + continuous_* logs Recruiter Bar Raiser panel → P3

User stories:

5b. Build list and seams touched

Item Kind Responsibility Reuse / new
TUS hook fan-out seam Fire-and-forget hint to analyzer per post-finish hook (≤ 250 ms); hook behavior unchanged New seam in tus.py
analyzer_sessions writer module Lease/fencing/state row per session (LIVE→DRAINING→DONE / BATCH_ONLY) Phase 0 skeleton
analysis_segments writer module Per-(tasksession_id, task_id) 20-min unit; MAP + status + lease_epoch Phase 0 skeleton
continuous_shadow_runs + DAO/model table Shadow result: 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 Would-be v2 report → EQ inputs + JSON to traces/continuous/shadow_result.json; canary-block result_analysis writes New
Live scoring driver module 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)
Token-bucket rate limiter module Client-side RPM+TPM pacing (Flash + Pro); 429 breaker + backoff New
Isolated Gemini key wiring config/CI GEMINI_API_KEY_CONTINUOUS in a separate GCP project New (P3 provisions)
Shadow selection module hash(tasksession_id) % 100 < CONTINUOUS_SHADOW_PCT OR position_id ∈ CONTINUOUS_SHADOW_POSITION_IDS New
continuous_shadow_report DAG Airflow Nightly EQ-1…EQ-6 → S3 markdown + Discord embed Reuses postmortem DAG
continuous_* logging observability Per-session drilldown to Better Stack Reuses runner.py:35-90

Seams in existing code:

5c. Data model and shared mechanisms

Three service-role tables (full DDL in the design doc; RLS-4, explicit FK ON DELETE, migrations via supabase migration new):

No chunk-ledger table (D8): chunk hooks are not durable — S3 is source of truth, reconciled by a 60 s S3 LIST sweeper over each instance's leased sessions. L1/L2 rows, session_digests, nudge/chat tables are Phase 3.

Mechanism Role in Phase 1
Lease + fencing Core — one owner per session; lease_epoch fences zombie writes. The soak validates it in prod.
CAS Guards the lease claim (claimed_by IS NULL OR lease_expires_at < now()) and each segment terminal write (status='ANALYZING' AND lease_epoch=:e).
Two runners Service + Fargate COPY the same video_analysis pipeline; Phase 1 proves both score equivalently.
Segment-index stamp Idempotent per-task footage arithmetic (1200 s → boundary); scoring-plane analogue of window_index.
Digest / version Not in play — awareness-plane, Phase 3.

Per R-MULTITASK, identity is per (tasksession_id, task_id): segments close on 1200 s of that task's footage; a task switch closes the open segment early.

5d. Exit gate — Measurable Success Criteria

Advance to Phase 2 only when all hold (EQ metrics defined in the design doc's "Migration & validation" + Appendix D — that copy is authoritative):

# Criterion Threshold
G1 Soak + volume ≥ 1 week continuous and ≥ 200 shadow sessions, ≥ 10 positions, both mechanisms (GITHUB/ZIP), both length strata, incl. ≥ 10 COMPLETED_NOT_SUBMITTED expiry + ≥ 5 stop/restart
G2 EQ-1 total score median |Δ| ≤ 5, P95 |Δ| ≤ 12, mean signed within ±3
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
G5 EQ-6 operational drain→report P95 ≤ 12 min; coverage ≥ 90% avg, ≥ 95% of sessions above the line; 0 sustained 429s
G6 Quota fit measured RPM/TPM per session fits the P3 quota with headroom; max-concurrent set (feeds K-8 clamp)
G7 Cost per-session Gemini cost within budget at prod mix, vs the P2-corrected pricing table
G8 Reliability clean ≥ 1 mid-drain restart → clean reclaim + resume; ≥ 1 real 429 → breaker fired, no batch/proctor starvation; last-100 EQ re-check green
G9 Zero-harm zero prod incidents from shadow; zero result_analysis writes (canary never converted)

5e. Soak, failure modes, kill switches

Watch during the soak week:

Kill switches (each fire-drilled once this phase; every switch leaves a working system — batch authors every report):

5f. Dependencies, assumptions, spec-kit slicing

Dependencies:

Assumptions:

Spec-kit slicing. One feature, folded into the Phase 0 "continuous scoring service — shadow" feature with a Phase-1 prod-wiring story cluster (fan-out, shadow store + writer, rate limiter, isolated key, selection, dashboards). Clean-cut alternative: a thin follow-on that depends-on Phase 0, boundary at offline mechanism vs prod wiring; the EQ gate is shared with Phase 0's replay. Near-serial: store + writer, then limiter + key, then dashboards — team of 1-2.


6. Phase 2 — Primary (report flip)

At a glance

Goal Service becomes primary author of task_sessions.result_analysis; report lands at T+4-9 min (was T+30-45). Fargate → standby/fallback.
Ships Finalize seam (resolver + FF_CONTINUOUS_PRIMARY + POST /finalize), sentinel-no-op Fargate hop, coverage auto-fallback + reconciler, recruiter-playback MP4. No new tables. Nothing candidate-facing.
Exit gate P50 latency ≤8 min / P95 ≤12; zero recruiter-visible discrepancy; auto-fallback <5%; kill-drill passes; playback + proctor on 100%.
Depends on Phase 1 shadow gate green (parity proven); P5 stream-copy spike (blocking); P2 cost fix carried forward.

Phase 2 ships latency, not accuracy: same CV activity-timeline pre-pass and same v2 MAP/REDUCE as batch (prompt 2.6.0, pinned identical), so scores are equivalent by construction — the parity Phase 1 measured.

Decision. result_analysis stays byte-identical (R-SHAPE) — no new keys, no schema_version bump. Timeline-fidelity/accuracy changes wait for Phase 3; scored-shape changes for Phase 5.

Decision. No separate fallback DAG. Submit always triggers the same tasksession_completion_fargate DAG with dag_run_id = client_submission_id; the Gemini sentinel (core.py:1626-1641) makes it a ~1-2 min no-op after a live finalize, or a full batch run otherwise. Fallback comes from not removing today's submit→DAG trigger.

(a) Scope

In scope Out → phase
resolve_analysis_trigger seam + FF_CONTINUOUS_PRIMARY L1/L2/digest windows → Phase 3
POST /finalize: 5s ack, ≤120s drain, tail MAP + v2 REDUCE as a library Nudges + chat → Phase 3
Sentinel-no-op hop so cert/notify/recommend run once result_analysis shape change / coachability bucket → Phase 5
Coverage <90% → batch auto-fallback (K-5) + reconciler Expiry fan-out onto continuous → Phase 4
Recruiter-playback MP4 (stream-copy, P5); pipeline_metadata.continuous stamp Cheaper single-plane L2 scoring → Phase 5

User stories: P1 faster report + guaranteed batch fallback + byte-identical shape; P2 one-flag revert, correct playback video; P3 reconciler re-triggers any session unscored after 20 min.

(b) Build list

Item Kind Reuse / new
resolve_analysis_trigger(tsid) -> "continuous"|"batch" module, shared/feature_flags.py, by task_completion_dag_id() new, mirrors FF_FARGATE_TASK_ANALYSIS
FF_CONTINUOUS_PRIMARY flag, mirrored FastAPI + service (I-7) new
POST /v1/sessions/{tsid}/tasks/{task_id}/finalize service endpoint; 5s ack, async drain owns the run new
finalizer (session_analyzer/core/finalizer.py) tail MAP → v2 REDUCE → update → finalize_task_session RPC reuse video_analysis + core.py as a library
sentinel-no-op hop trigger same DAG, dag_run_id=client_submission_id reuse airflow_utils.trigger_dag
coverage eligibility + HANDOFF <CONTINUOUS_MIN_WINDOW_COVERAGE_PCT (90) → batch new (reads analyzer_sessions/analysis_segments)
reconciler watchdog 10-min cron; COMPLETED >20 min w/o report → re-trigger new, expiry-DAG precedent
recruiter-playback MP4 (video_builder) incremental ffmpeg -c copyscreenshare_video at drain new, gated on P5
pipeline_metadata.continuous stamp analysis_source, config, coverage — alongside result_analysis new (R-SHAPE)

(c) The finalize seam

Both submit endpoints call trigger_dag(…, dag_run_id=client_submission_id) today — …task_sessions.py:482-488 (GITHUB), :910-916 (ZIP). Phase 2 inserts the resolver in front; the batch branch is unchanged. E2B /submit stays untouched — it never triggers (2026-06-02 double-fire). Expiry keeps expiry-{tsid}-{tid}, on batch until 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): return "batch"  # nothing to drain
    return "continuous"

Finalize (async after the 202): eligibility + coverage ≥90% (else HANDOFF) → drain ≤120s → tail MAP + run_v2_reduce (synthesis.py:932-1053) → combine_v2_buckets (result_combiner.py:374-463) → core.update_supabase (core.py:2642-2878) → finalize_task_session RPC (core.py:2897-2899) → utkrusht_score (core.py:2948-2971) → DAG hop. Any failure/HANDOFF → identical trigger_dag, full batch. The hop's read_outcome rebuilds won from DB (task_analysis_fargate_dag.py:426-505), so cert (:180-209), notify, and recommend fire once — no side-effects duplicated.

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

Idempotency spine. One client_submission_id per (session, task), minted in FE localStorage (usePerTaskSubmit.ts:18-29), is the shared key across FE retries, service dedup, Airflow dag_run_id 409-as-success (airflow_utils.py:89-127), and the reconciler. Only other namespace: expiry-{tsid}-{tid}.

Race / retry Why it is safe
FE retries submit → endpoint calls service twice Service dedupes on (tsid, task_id); 2nd call returns the live/finished job (200 {already:true})
Service hop AND endpoint fell back to a direct trigger (5s-timeout race) Both use dag_run_id=client_submission_id → Airflow 409 → success; one run exists
Fallback batch starts while a slow finalize also completes upsert_task_analysis_entry replaces per-task_id; RPC returns already_finalized to the loser — wasted spend at worst, never corrupt
Batch starts, service has not written, service dies Batch does the full analysis — indistinguishable from today
Reconciler double-fire Re-triggers with client_submission_id from task_submissions[task_id], else reconcile-{tsid}-{tid}; a duplicate is cost, not corruption

(d) Data model + shared mechanisms

No new tables. Reuses analyzer_sessions and analysis_segments from Phase 0/1 (DDL in the Data model section). Deltas are write targets: real result_analysis (byte-identical) plus score, utkrusht_verified_skills, screenshare_video, and a new pipeline_metadata.continuous block (analysis_source, window/segment config, coverage_pct) beside result_analysis, never inside it (R-SHAPE). Key Entities: that stamp + client_submission_id.

Mechanisms: two runners (load-bearing — /finalize imports v2 REDUCE + core.py finalize as a library; both images build-time-COPY airflow/dags/video_analysis/**, so batch/live never drift); lease + fencing (one finalize owner; racing batch arbitrated by RPC + sentinel); three stamps (window_index gates re-MAP, pipeline_metadata.continuous = run stamp, lease_epoch fences zombies); CAS light. Digest CAS fold is Phase 3.

(e) Exit gate — Measurable Success Criteria

# Condition Threshold to advance
G1 Report latency, submit → ANALYSIS_DONE (EQ-6) P50 ≤8 min, P95 ≤12 min, held over the soak
G2 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 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 (R-9)
G8 Soak duration ≥1 week; reversed-shadow 10% continues ≥4 weeks into Phase 3

Warning. No 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 a no-op hop will not build the combined MP4 — the service must produce screenshare_video itself via incremental -c copy concat at drain. Proctor concat has no sentinel, so proctor_video is rebuilt by the hop for free — verify it in shadow.

(f) Soak, failure modes, kill switches

Watch during the soak: - Reversed shadow: batch in shadow on a 10% sample for 4 weeks (roles reversed → continuous_shadow_runs); nightly continuous_shadow_report DAG compares and posts a Discord embed. - BetterStack continuous_*: continuous_drain_start, continuous_finalize, continuous_coverage_gap, continuous_circuit_open + per-stage latency_ms. - Alerts: fallback >5% daily → investigate, >15% → auto-page; any session unscored by T+45 → page; duplicate-run (R-9); Gemini-budget breach. - Distributions: submit→ANALYSIS_DONE percentiles, coverage histogram, cost/session at P2-corrected pricing vs the Google bill.

Switch / mode Effect
K-2 FF_CONTINUOUS_PRIMARY=false submits revert to the current trigger_dag; in-flight finalizes complete harmlessly (RPC arbitrates)
K-5 coverage <90% → HANDOFF that report is produced by batch under the same client_submission_id; automatic
K-1 ingest flag off resolver returns "batch" (nothing consumed to drain)
K-7 / K-8 Gemini circuit breaker / concurrency clamp live calls pause / clamped sessions run pure batch; K-5 catches drain lapses
K-9 FF_FARGATE_TASK_ANALYSIS unchanged — steers the batch fallback 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. Retired risks: R-4 (playback/proctor → R-MP4/R-PROCTOR gate), R-5 (score/tags drift → library reuse), R-9 (triple-fire → idempotency spine).

(g) Dependencies, assumptions, spec-kit slicing

Depends on: Phase 1 gate green (EQ-1…EQ-6 over ≥200 sessions / 3 weeks — the parity proof is the entry condition; accuracy is not re-litigated); P5 blocking (playback MP4); Phase 0 fault-injection reused for the G4 drill; P2 cost fix carried forward. P1-P5 land in parallel; only P5 + the Phase 1 gate block Phase 2.

Assumptions: - Phase 1 proved continuous ≡ batch (same pinned prompt 2.6.0 + calculator). - Sentinel per core.py:1626-1641: any non-placeholder result_analysis → batch skips download/ffmpeg/Gemini, exits ~1-2 min. - client_submission_id reliably minted by FE (usePerTaskSubmit.ts:18-29); reconciler absorbs the best-effort task_submissions gap. - Fargate cap=1 is fine — the report is already live; notifications fire on the Realtime status change; only cert/postmortem/recommended lag (minutes-tolerant).

Spec-kit: one feature — "continuous scoring — primary + finalize seam." The seam is the spine; playback MP4 and reconciler are P2/P3 stories within it, not separate features. P5 is a standalone prereq PR before the spec. Key Entities is short (the pipeline_metadata.continuous stamp), so FRs concentrate on the seam contract, idempotency, and exit-gate SLOs. Team of 1-2, near-serial: P5 spike → playback + /finalize finalizer → resolver + reconciler → fire drills → soak.


7. Phase 3 — Awareness plane (Bar Raiser + chat)

At a glance

Goal Turn the live pipeline into a helper: build the L1/L2/digest hierarchy, then ride a Bar Raiser nudge engine and a candidate chat on top.
Ships Nudge engine, chat, 4 service-role tables, report addendum panel. Candidate-facing, additive, fail-soft — no scored number changes.
Exit gate ≥300 human-reviewed simulated nudges with zero solution leakage (pre-real-candidate); then per-position opt-in soak.
Depends on Phases 0–2 done; P1 (skill scopes, blocking); P3 (separate GCP key).

Highest-scrutiny phase: the system speaks to the candidate for the first time. A wrong nudge or leaked hint is the most visible failure the platform can make, so it ships last behind a hard gate.

Decision. Candidate-facing, gated twice. Pre-launch: ≥300 human-reviewed simulated nudges must show zero solution leakage through the guardrail. Then per-position (positions.nudges_enabled, forward-only) on a few friendly design-partner positions, watched for veto rate and quality. Never a silent backfill.

Decision. The report stays byte-identical (R-SHAPE). Nudges and chat reach recruiters via an addendum read path composing 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. Coachability as a scored bucket is Phase 5.

7a. Scope

Value: a fairer, less lonely assessment for the candidate; a verbatim, auditable Bar Raiser transcript plus a coachability signal for the recruiter — no scored number touched. Mechanism detail is in the design doc; this owns build order, seams, gates.

In scope Out of scope (owner)
L1 1-min window analyzer (single-turn Flash, inline bytes, carry-chain) Overlapped windows — rejected
L2 5-min coalesce + capped-rewrite digest fold (one CAS-versioned Flash call) A 3rd (15-min) layer — rejected; lookback reads 3 L2s
Bar Raiser nudge engine (gate → Flash judgment → fail-closed guardrail) Coachability as a scored bucket → Phase 5
Candidate chat (send endpoint, analyzer_outbox, responder, dock, broadcast) Swap scoring MAP timeline CV→L1 (timeline_source=l1) → gated follow-up (7f)
Report addendum read path + recruiter transcript panel Any scored MAP/REDUCE prompt change (R-AWARE-SCORING)
positions.nudges_enabled opt-in + task_sessions.nudges_active stamp Per-candidate RLS hardening of chat tables → deferred
Prompt 2.7.0 (chat-panel-on-screen note) + persona NUDGE_PROMPT_VERSION 1.0.0 Single-plane scoring-from-L2 (−62% cost) → Phase 5; sound/notification alerts → post-v1

7b. User stories (P1/P2/P3)

Pri Story
P1 Candidate stuck ~15 min → one calm, non-judgmental question that redirects, no answer handed over.
P1 Candidate → ask what the task requires and time left, so ambiguity doesn't block them.
P1 Hiring team → every nudge and chat message shown verbatim with evidence, to judge fairly.
P2 Operator → enable per position, kill globally in one flip (blast-radius control).
P2 Candidate who ignores chat → silence costs nothing (no-penalty invariant).
P3 Prompt engineer → every gate/judgment/guardrail verdict persisted (incl. suppressed) for offline tuning.

7c. Build list

Item Kind Responsibility · reuse
L1 window analyzer module 60s concat → single-turn Flash on inline bytes → ActivityBlock timeline + carry_out; reuses timeline_schemas
L2 coalesce + digest fold module 5-min coalesce over 5 L1s (overlap-1) + capped-rewrite digest, one Flash call (R-COST)
session_digests table versioned digest per (tasksession_id, task_id); version == len(applied_l2)
candidate_nudges table decision ledger incl. SILENT/suppressed + guardrail verdict, evidence, policy_hash
task_session_chat_messages table transcript; service-role only, task_id NOT NULL, client_msg_id idempotency
analyzer_outbox table at-least-once delivery (write→deliver→stamp); copies notifications/ pattern
Nudge engine module gate → Bar Raiser Flash judgment → guardrail → send/drop
Guardrail validator module deterministic lint + separate Flash veto turn; fail-closed, one regen
Chat send endpoint endpoint POST /v2/task-sessions/{id}/chat-messages; first authenticated /v2 route
Chat responder + broadcast module claim outbox → reply → insert → broadcast chat:{tasksession_id}
Next API routes (send/history/seen) frontend candidate JWT + ownership; appendScreenshareEvent precedent
BarRaiserChatDock + useBarRaiserChat frontend collapsed dock, toast, tab-title counter, subscribe + history
Report addendum + recruiter panel endpoint + FE 2 tables + pipeline_metadata.continuous beside result_analysis
Persona + guardrail prompts prompt NUDGE_PROMPT_VERSION 1.0.0, verbatim in appendix
Scoring prompt 2.7.0 prompt chat-panel note; bumps PROMPT_VERSION
positions.nudges_enabled / task_sessions.nudges_active columns per-position opt-in + creation-time stamp
Simulated-nudge harness + review UI CI ≥300 nudges over recorded sessions; extends Phase 0 harness
FF_BAR_RAISER_NUDGES, FF_CANDIDATE_CHAT config global kill switches

7d. Seams touched (existing code)

7e. Data model + shared mechanisms

Four service-role tables (build list above), L1/L2 rows added to the Phase-0 analysis_windows table, two columns (positions.nudges_enabled DEFAULT false, task_sessions.nudges_active). Identity per (tasksession_id, task_id) (R-MULTITASK). Each table: DAO + Pydantic, RLS-4, explicit FK ON DELETE, service-role; chat/nudge tables have no anon SELECT (R-REALTIME). Full DDL in the Data model section.

Mechanism Use here
CAS session_digests.version advances by WHERE version = N; double-apply impossible; version != len(applied_l2) triggers recompute from L2 rows.
Lease + fencing Nudge engine and chat responder run only on leased sessions; every write carries lease_epoch, so a zombie's stale nudge lands 0 rows.
Digest The running per-task fold is the top layer; rides the L2 call (one combined Flash call per 5-min boundary).
Outbox analyzer_outbox gives at-least-once delivery; the per-minute tick sweeps unclaimed/abandoned rows.
Two runners Untouched — scoring still COPYs airflow/dags/video_analysis; the awareness plane is separate, service-owned code.

7f. 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 pipeline can only make the engine more conservative: a failed gate skips the LLM, the judgment may decline, and the fail-closed guardrail drops anything unsafe.

The gate's nudge caps (all config-driven, per the _resolve_max_concurrent_analyses precedent at task_analysis_fargate_dag.py:141-177):

Cap Default Why
Quiet head 10 min need ≥2 closed L2 windows before any pattern claim
Quiet tail 5 min a late question only adds stress
Cooldown 10 min max 1 nudge / 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 Simulated nudges, zero solution leakage through the guardrail ≥300, 0 leaks — pre-real-candidate gate
G-2 Guardrail veto rate on real candidates tracked, stable; 0 leaked messages reach a candidate
G-3 Chat reply latency p50 ≤8s, p95 ≤15s
G-4 Nudge freshness / gap safety 0 nudges 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
G-7 No-penalty invariant zero-interaction candidate scores identically to Phase-2 three-bucket math

7g. Soak, failure modes, kill switches

Watch: BetterStack continuous_* ops carry task_session_id + latency_ms per stage (l1, l2, digest, nudge_gate, nudge_decision, guardrail_veto, chat_reply, chat_veto, injection_suspected).

7h. Dependencies + assumptions

7i. Spec-kit slicing note

Three features on a near-serial spine; the ≥300 zero-leak gate blocks both consumers from reaching a real candidate:

  1. Awareness ingest — L1/L2 rows on analysis_windows, session_digests (CAS), flag columns, coverage observability. Soaks on the replay harness first.
  2. Bar Raiser nudge enginecandidate_nudges, gate → judgment → guardrail, persona 1.0.0, nudge half of the addendum. Owns the ≥300 gate.
  3. Candidate chattask_session_chat_messages, analyzer_outbox, authed send endpoint + responder, dock, broadcast, chat half of the addendum.

Specify the shared seam (guardrail, answer-policy prompt, addendum read path) once in feature 2, consume in 3. Team-of-1–2 order: P1 → foundation → chat (reactive, safer) → nudge engine (proactive, high-scrutiny). P1 and P3 are small standalone PRs ahead of feature 1.


8. Data model across phases

The schema, sliced by phase so each phase's data-model.md cuts clean. The design doc holds the full DDL and ER diagram (Figure 5.1); this owns the ordering — which migration ships which table, with what key and FK action.

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 adds no schema — it is a write-path change at the finalize seam plus a pipeline_metadata.continuous JSONB stamp. All go through supabase migration new.

8.1 Master table: entity × phase

Every table keys on (tasksession_id, task_id). tasksession_idtask_sessions is ON DELETE CASCADE; task_id is a plain UUID, no FK. The FK column shows only deviations from that default.

Entity Phase Key columns Idempotency key FK deviation
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)
analysis_segments 0 segment_index, status, chunk_range, map_result, segment_video_key, lease_epoch PK (…, segment_index)
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 (…, level, session_prefix, window_index)
continuous_shadow_runs 1 shadow_result, batch_result, parity, pipeline_metadata, status UNIQUE (tasksession_id, task_id)
session_digests 3 digest, applied_l2, version, narrative_stale PK (tasksession_id, task_id) + version CAS
candidate_nudges 3 evaluation_key, decision, question, signals, llm_reasoning, inputs_hash, delivered_at UNIQUE (…, evaluation_key)
task_session_chat_messages 3 role, content, nudge_id, client_msg_id, policy_action, evidence UNIQUE (tasksession_id, client_msg_id) WHERE client_msg_id IS NOT NULL nudge_idcandidate_nudges SET NULL
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

Mechanisms per table: analyzer_sessions = lease + fencing (lease_epoch fences zombie writes); session_digests = CAS on version; windows/segments = index stamps (at-least-once → exactly-once).

8.2 Phase 0 — scoring-plane and lease tables

analyzer_sessions is the state machine and the lease. The lease fields are load-bearing from day one — the replay harness exercises lease-steal and stale-epoch fencing offline first.

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,
  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 knobs, frozen at claim
  context jsonb,            -- task blob + position scopes, fetched once (awareness plane only)
  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, 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)
);

Per (session, task) because result_analysis is stamped with task_id and merged via upsert_task_analysis_entry (20260522131458); finalize_task_session counts distinct task_id (20260526170000). config freezes window knobs at claim so a mid-flight change cannot re-shard existing windows.

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,             -- verbatim run_v2_map_unit output, REDUCE input
  segment_video_key text,           -- pre-encoded playback MP4
  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) is created here for its idempotency key, partial index, and coverage fields, but its L1/L2 rows are only written in Phase 3; through Phases 0-2 the scoring plane uses the CV activity-timeline pre-pass, not L1 windows.

Warning. 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 static that does not reset on a task switch (chunkStorage.ts:303-306), so a raw divisor would start task 2 mid-stream and corrupt every arithmetic treating window_index as per-task footage (segment boundaries, nudge lookback, the digest fold). task_anchor is a MIN over persisted rows, so a late or replayed event cannot shift it. Same for analysis_segments.

8.3 Phase 1 — the shadow table

One table. The live path must never write result_analysis until parity is proven — the batch sentinel treats any non-placeholder entry as "already done", so a wrong live write would defeat the fallback. The would-be report lands here and in S3 instead, with the captured batch result.

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,
  client_submission_id text,             -- ties this run to the batch DAG run
  shadow_result  jsonb NOT NULL,         -- result_analysis entry the live path WOULD write
  pipeline_metadata jsonb NOT NULL,      -- continuous stamp: model, prompt_version, latency_ms, cost
  batch_result   jsonb,                  -- Fargate's actual entry (null until batch lands)
  parity         jsonb,                  -- EQ-gate metrics: score |Δ|, sub-dim agreement, citations
  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-identical to what the live path would write, so the parity job diffs it against batch_result for EQ-1..6 (score median |Δ|, sub-dim agreement, recommended-flip rate, timeline recall, citations, T+10 SLO). The UNIQUE key makes finalize idempotent.

8.4 Phase 2 — no schema delta

No new table or column. The finalize seam writes the real result_analysis through the reused pipeline, plus a pipeline_metadata.continuous JSONB stamp. result_analysis stays byte-compatible (no new keys, no schema_version bump), so this phase's data-model.md is zero entities — only the write-path change and fallback contract.

8.5 Phase 3 — awareness-plane tables and flag columns

Four tables plus two flags in one migration (DDL authoritative in the design doc). Load-bearing constraints:

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 — copying the positions.scoring_version seam (2026061000000020260621054203). A recruiter toggling the position mid-assessment must not change a running candidate's experience; nudged and un-nudged cohorts must never mix silently; and the analyzer resolves from its own session row instead of joining positions every L2 tick.

8.6 Cross-phase conventions

Lift these into each phase's data-model.md. One convention, four parts: every new table keys on per-task identity (tasksession_id, task_id) in per-task footage seconds (never global chunk_num or wall-clock; a task switch closes the open window with a gap); gets an explicit FK ON DELETE (tasksession_id CASCADE, task_id no FK since task_sessions holds tasks as JSONB per _task_common.py:39-71, chat.nudge_id SET NULL, analyzer_outbox none); gets the RLS-4 service-role posture (enable RLS, no policies, revoke anon/authenticated, grant service_role; precedent task_competencies 20260604084807); and gets a DAO + Pydantic pair only if more than one service reads it.

Only candidate_nudges and task_session_chat_messages clear that DAO bar (both read by Flask at report time). The other six are analyzer-only and live service-local under session_analyzer/db/; the flag columns ride the existing task_sessions/positions models.

No browser reads any table directly — chat delivery is Supabase Realtime broadcast on chat:{tasksession_id} (no backing-table read); postgres_changes is avoided (the report_comments permissive-policy trap, 20260606090000…:1-21).


9. Cross-cutting: flags, observability, rollback

Every phase inherits this envelope — one flag set, the continuous_* logs, this rollback posture — pulled into its specify wholesale. Three invariants hold throughout: one author per report (service finalize seam or batch DAG, batch always the backstop, shape byte-compatible v2, I-5); report-safe by construction (any flip still yields a report for every session); drilled once (every switch fire-drilled before its phase exits soak).

9.1 Feature flags and kill switches

All env flags resolve through shared/feature_flags.py, mirrored into the service config the way FF_FARGATE_TASK_ANALYSIS is mirrored into airflow/dags/utils/feature_flags.py (I-7); the service, like Fargate, doesn't ship shared/.

Flag / switch Scope Disables Phase Fire-drill
FF_CONTINUOUS_L1 TUS-hook fan-out + session_analyzer Fan-out; no new windows; resolve_analysis_trigger()batch 1 Flip off mid-session in dev; 90% auto-fallback finalizes in-flight
FF_CONTINUOUS_PRIMARY FastAPI submit (/complete, /submissions) Service authorship; submit reverts to trigger_dag(dag_run_id=client_submission_id) 2 Dev P1 + early prod P2: confirm current path resumes
positions.nudges_enabled DB column/position, NOT NULL DEFAULT false, stamps nudges_active Nudges+chat for new sessions; stamped sessions keep stamp 3 New sessions stamp false; in-flight unchanged
FF_BAR_RAISER_NUDGES session_analyzer, global All nudge gen+delivery incl. in-flight; report stamps nudges_suppressed_at 3 Emergency kill; verify suppression + honest stamp
FF_CANDIDATE_CHAT Candidate app + service chat endpoints, global Chat writes rejected; panel → neutral "reviewer stepped away" (R-OPS) 3 Verify writes rejected + neutral UX
Gemini 429 / spend breaker Automatic; per-session + global CB_429_THRESHOLD=5 in CB_429_WINDOW=10 min; per-session $2.50 cap +80% alert; launches pause, windows deferred, session→BATCH_ONLY 1 Inject 429 storm in P0 replay + P1 dev; verify trip + auto-fallback
Concurrency clamp Env dial (max live sessions, from P1) Overflow not shadow-selected (deterministic hash) → pure batch 1 Set clamp low; overflow runs batch, 0 report impact
FF_FARGATE_TASK_ANALYSIS (existing) shared/feature_flags.py:24-33 Steers batch fallback: Fargate DAG vs legacy in-Airflow DAG pre Unchanged; kept until Phase 4 deletes legacy DAG

Two ramp dials (not kill switches) sit alongside: CONTINUOUS_SHADOW_PCT / _POSITION_IDS (hash(tasksession_id) % 100, shadow-for-life) and CONTINUOUS_MIN_WINDOW_COVERAGE_PCT (default 90, the finalize / auto-fallback line). Design doc maps the table to switches K-1…K-9.

Warning. The shadow flag must never let the service write task_sessions.result_analysis. A non-placeholder entry trips the batch Gemini sentinel (I-4): the real batch run no-ops and returns the shadow entry verbatim, silently promoting unproven code. Phase 1 writes go to continuous_shadow_runs + S3 only; a canary assertion refuses to build an upsert_task_analysis_entry call in shadow mode.

9.2 Observability

No metrics infra today — logs (Better Stack, operation + task_session_id) plus pipeline_metadata JSONB (I-10). The service ships via LOGTAIL_SOURCE_TOKEN_* (runner.py:35-90) under the continuous_* namespace (_l1_window_done, _l2_coalesce, _drain_start/_shadow_finalize/_finalize, _coverage_gap/_circuit_open/_fallback, _nudge_decision/_chat_reply, _reconciler_fire), each with latency_ms + stamps. Nightly DAG continuous_shadow_report joins continuous_shadow_runs × task_sessions at ANALYSIS_DONE, computes EQ-1…EQ-6, writes s3://…/continuous_shadow/{date}.md, posts a Discord embed. Phase 2 reverses it — batch shadows a 10% sample for 4 weeks so EQ keeps running against the now-primary output.

What each soak watches (the advance signal):

Phase Soak Primary watch Advance when
0 — Build & Replay Offline, repeatable EQ-1…EQ-5 on ≥50-session corpus; fault-injection = zero silent degradation; two window configs prove config-driven sizing Metrics reproduce within noise floor
1 — Shadow ≥3 wk / ≥200 prod sessions EQ-1…EQ-6 rolling (+ last-100 re-check); coverage ≥95% above the 90% line; measured Gemini RPM/TPM; cost/session; zero shadow-attributable incidents All EQ green + drain P95 ≤ 12 min
2 — Primary ≥4 wk Fallback rate (>5% investigate, >15% auto-page); reversed-shadow EQ green; latency P50 ≤ 8 / P95 ≤ 12 min; reconciler firings; R-MP4 / R-PROCTOR on 100% Fallback < 5%; no report later than batch T+45
3 — Awareness Dogfood → design partners → GA Solution-leakage = 0 (launch blocker); completion on nudged positions vs pre-nudge baseline; complaint rate ~0; nudge caps; chat reply p50 ≤ 8 s / p95 ≤ 15 s ≥300 reviewed simulated nudges, zero leakage, clean design-partner window

"Report missing" triage extends /track-task: session stamp / pipeline_metadata.analysis_source → service finalize-job → reconciler log → DAG run (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 a ~1–2 min no-op when the live path already finalized, or a full batch run when it didn't. The backstop comes from 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 Author after
1 — Shadow FF_CONTINUOUS_L1=false Fan-out stops; batch was always sole author of every prod artifact Batch (unchanged)
2 — Primary FF_CONTINUOUS_PRIMARY=false Submit reverts to current trigger_dag; in-flight service finalize completes harmlessly (same writes batch would make; finalize_task_session RPC last-writer-wins, already_finalized-safe) Batch, forward
3 — Awareness nudges_enabled=false (new) / FF_BAR_RAISER_NUDGES=false (global) / FF_CANDIDATE_CHAT=false (chat) Awareness plane is additive + fail-soft; scoring/report path untouched Unchanged — off the critical path

Last resort for a report-less submit: the reconciler watchdog (10-min cadence, expiry-DAG precedent) finds a COMPLETED session past 20 min with a submitted task lacking a real result_analysis and no active DAG run, then triggers batch by client_submission_id. A duplicate run is cost, not corruption — that one idempotency spine collapses FE retries, service dedup, Airflow 409-dedup, and reconciler into safe overwrites.

9.4 Reliability posture and deployment

Decision — pace, don't discover. The service paces its 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 ceiling by taking 429s; the breaker is the safety net, not the throttle.

Deployment. session_analyzer is a Coolify ARM64 service modeled on notifications/, its own GHCR image (ghcr.io/ngm9/) build-time-COPYing airflow/dags/video_analysis/ like the Fargate runner — one pipeline, two runners, zero forks. CI path filter watches airflow/dags/video_analysis/** for both images so logic can't drift. CI/CD: main→dev (udev-1), release→prod. Placement stays open for the phase specs (uprod-be-1 vs dedicated box vs ECS), but at 350 concurrent it's a dedicated ARM host — ~30–50 live sessions/instance (~85% LLM idle-wait), horizontal scale by lease-based ownership, no sticky routing.


10. Spec-kit handoff

Everything above is pre-spec input. Each phase runs its own specifyplantasks cycle into a specs/NNN-* dir — you never specify the whole plan at once. Phases don't map 1:1 to features: 0 and 1 merge (one codebase, one data model); Phase 3 splits into two independently-flaggable features. Confirm names and numbers at build time.

Phase → feature mapping

Phase Feature name(s) Spec scope P1/P2/P3 stories Key entities Success criteria (= exit gate)
0 + 1 continuous scoring service — shadow 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 byte-identical report to shadow table; P1 lease + fencing = one owner/session with crash-resume; P2 replay harness + fault injection proving offline parity; P3 per-stage cost/latency to Better Stack. analyzer_sessions, analysis_windows, analysis_segments, continuous_shadow_runs Shadow parity: score median |Δ|≤5, P95≤12; zero result_analysis writes; window coverage + continuous_* latencies in budget.
2 continuous scoring — primary + finalize seam 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 T+4-9 min via live path; P1 batch auto-fallback by construction when service down/ineligible; P2 reversed-shadow batch on 10% for parity; P2 stream-copy playback MP4 (P5 lands here). None new; pipeline_metadata.continuous stamp. T+10 report SLO P50≤8 min / P95≤12; fallback fire-drilled once; recommended-flip ≤5% on reversed-shadow cohort.
3 bar-raiser nudge engine L1/L2/digest ingest + 3-stage nudge engine (deterministic gate → Bar Raiser judgment → guardrail veto), per-position opt-in, decisions as recruiter-report addendum. P1 gate + judgment + guardrail, 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; cols positions.nudges_enabled, task_sessions.nudges_active ≥300 human-reviewed simulated nudges, zero solution leakage before first real candidate; nudge visible ~30-110 s after closing 5-min boundary.
3 candidate chat Candidate ↔ Bar Raiser chat: authenticated Next.js route → service-role insert → outbox → analyzer reply → Supabase Realtime → candidate panel. P1 candidate asks, reply p50≤8 s / p95≤15 s; P1 solution-seeking deflected per 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 reviewed corpus; tasks.solutions structurally absent from every chat context.

Decision. Each phase's exit gate becomes its feature's Measurable Success Criteria verbatim. The gates (EQ-1..6, the T+10 SLO, the ≥300-nudge bar) are testable thresholds so a specify prompt lifts them into spec.md unchanged.

Numbering. Backend specs → Utkrushta/specs/NNN-*; next free 022-025 in phase order (highest = 021). Phase-3 frontends are thin companion specs: recruiter recruiter-utkrusht/specs/021, candidate utkrushta-assessment/specs/001. P1-P5 are plain PRs or one-page specs, not phase features.

Definition of done (this planning doc)


Utkrusht — Meaningful Skill Assessments for India