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:
- Phase 0 — build the service + scoring plane, prove it offline against recorded prod sessions.
- Phase 1 — run it live in prod, write to a shadow store only; Fargate still owns the report.
- Phase 2 — flip the report to the service — the latency win; Fargate demoted to standby.
- Phase 3 — add the candidate-facing awareness plane (nudges + chat), per-position opt-in.
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_analysisas "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 writesresult_analysis; shadow output goes tocontinuous_shadow_runs+ S3 only (R-IDLE). Phase 2 lets it write the real column only after shadow proves parity.
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.
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.yamland its newsession-analyzertwin) must includeairflow/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.configat session start. Retuningl1_window_s/l2_window_smid-session re-shards existing windows and breakswindow_indexidempotency (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 |
- P1 — Resolver + context-payload field + tests; scopes feed nudge/chat judgment only.
- P2 —
GEMINI_FLASH_PRICING(config.py:16-42) holds 2.5-flash rates andgenerate_tagsis priced on the Pro table (core.py:2584), so every cost figure (incl. the $1.10 avg) under-reports ~40% input / ~17% output. - P3 — Two deliverables: the recorded quota (first input to the 350-concurrent targets — Flash ≥1,000 RPM / ≥12M TPM, Pro ≥150 RPM / ≥3M TPM) and the isolated project/key;
_DEVunblocks Phase 0 replay, prod key + quota is the Phase 1 blocker. - P4 — Tiny PR: ACL param on the continuous S3 writer + a test asserting no
public-read; replay is the first writer of these candidate-reasoning artifacts. - P5 — ~30-min spike then PR; the single-session path already has a
copy2variant (core.py:1956-1961); no report flip until the MP4 exists on 100% of shadow sessions.
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_reduce → combine_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/) |
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)
analyzer_sessions— one row per(tasksession_id, task_id): state machine (IDLE|LIVE|DRAINING|FINALIZING|DONE|FAILED_FALLBACK), lease (claimed_by,lease_expires_at, heartbeat,lease_epochfencing), frozenconfig, taskcontext.UNIQUE(tasksession_id, task_id).analysis_segments— scoring-plane 20-min MAP units:map_result,token_usage,latency_ms,chunk_range, status; PK(tasksession_id, task_id, segment_index).- L1/L2 rows +
session_digests→ Phase 3;continuous_shadow_runs→ Phase 1. In P0 the comparator writes only harness artifacts, never a report column.
Shared mechanisms in play
- Lease + fencing on
analyzer_sessions; the restart fault proves reclaim-and-resume. - CAS on segment claim (
WHERE status='PENDING' AND lease_epoch=…); the digest CAS fold is Phase 3. - Two runners — the Dockerfile COPYs
video_analysis; CI path filter watchesairflow/dags/video_analysis/**for both images. It is import-clean (Fargate proves it). - Identity per
(tasksession_id, task_id)— segments close on 1200s of per-task footage, not wall-clock or global chunk-count; a task switch closes the open segment early.
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):
- Better Stack
continuous_*ops (_segment_map_done,_tail_map,_reduce,_finalize,_sweep,_lease_claim,_lease_lost,_coverage_gap,_circuit_open), each withlatency_ms+token_usage+task_session_id— the source for retuning segment/grace knobs. - Comparator report (md + JSON at
s3://…/dag_reports/continuous_replay/{run}.md): EQ aggregates, EQ-3 flip list, EQ-5 citation sample, per-session drill-down. - Dev cost/latency vs the batch $1.10 average (needs the P2 Flash-pricing fix, else ~40% under-report).
- One fault-run report per scenario: coverage accurate to ±1 window, no full-confidence output on a degraded run.
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:
- Historical prod chunks still exist at deterministic S3 keys; the corpus builder verifies inventory and re-runs batch v2 for any missing baseline (26 sessions have full
pipeline_metadata; the rest re-derived to reach ≥50). - Corpus stratified by length (<20-min single-pass vs >40-min segmented; median footage ≈ 22 min), mechanism (GITHUB vs ZIP), ≥8 positions.
- Prompt 2.6.0 +
calculator_v2pinned identical across both pipelines for the whole window; any bump resets the comparison clock. - Accelerated replay is valid because boundaries derive from
(session_ts, chunk_num), not wall-clock; at temperature 0.3 the noise floor is measured before any delta is judged.
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_analysisin 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 flipANALYSIS_DONEbefore batch ran. Enforced by a canary assertion (R-1): the service refuses to construct anupsert_task_analysis_entry/finalize_task_sessioncall in shadow mode. Shadow writes go tocontinuous_shadow_runs+ S3 only.
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 / score → P2 |
| 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:
- (P1) Score real prod sessions live, write only to shadow; a nightly EQ-1…EQ-6 dashboard decides when parity justifies the flip.
- (P1) Isolated key + rate limiter sized to the P3 quota, so shadow can never 429-starve batch or proctor.
- (P2)
continuous_*ops expose lease/restart/429/drain; a deterministic ramp dial grows coverage without half-analyzing a session. - (P3) Per-session Gemini cost tracked against P2-corrected pricing, validating the budget at prod mix.
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:
- TUS post-finish (
tus.py:152-274) — fan-out gated onFF_CONTINUOUS_L1+ selection; self-copy / cover-video /{}response untouched; ≤ 250 ms so hook latency (0 fails / 23,372 calls, peak 1.4/s) can't regress. - Scoring library — build-time COPY of
airflow/dags/video_analysis/**; reuserun_v2_map_unit/run_v2_reduce(synthesis.py:932-1053) verbatim; CI path filter watches that dir for both images. - Prod-state reads — read-only PostgREST embeds (position config, task blobs,
screenshare_timeline); notask_sessionswrites. - Submit NOT touched —
v2/task_sessions.py:482-488(GITHUB) +:910-916(ZIP) stilltrigger_dag(...); the/finalizeseam is Phase 2.
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):
analyzer_sessions— one row pertasksession_id; lease (claimed_by,lease_expires_at, heartbeat),lease_epoch, state machine,config_snapshot, breaker counters.analysis_segments— 20-min scoring unit, identity(tasksession_id, task_id, segment_index); MAP JSONB, status,lease_epoch,prompt_version,model,token_usage.continuous_shadow_runs— shadow result + EQ metrics per(tasksession_id, task_id); joined totask_sessionsatANALYSIS_DONE.
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:
continuous_*ops (Better Stack):segment_map_done,drain_start,shadow_finalize,coverage_gap,circuit_open,lease_reclaim,budget_warning.- Nightly
continuous_shadow_report: rolling EQ-1…EQ-6 → S3 markdown + Discord; joincontinuous_shadow_runs×task_sessionsatANALYSIS_DONE. - Human + capacity: weekly hand-review of the EQ-3 flip list + EQ-5 citations; RPM/TPM per session vs P3 quota; per-session cost vs P2 pricing.
- Coverage: characterize the sub-90% population (crash-reload, stop/restart, stragglers) — what Phase 2 auto-fallback must absorb.
Kill switches (each fire-drilled once this phase; every switch leaves a working system — batch authors every report):
- Sentinel (R-1) — canary assertion + shadow-only write path, backed by a code-review checklist item.
FF_CONTINUOUS_L1=false(K-1) — mirrored on FastAPI + service (shared/feature_flags.py:24-33); stops fan-out and all shadow work.CONTINUOUS_SHADOW_PCT/_POSITION_IDS— ramp dial; drop to 0 to quiesce shadow instantly, no redeploy.- Gemini breaker (K-7) + concurrency clamp (K-8) — 429s pause launches with backoff, defer segments, alert; sessions past the clamp run pure batch.
5f. Dependencies, assumptions, spec-kit slicing
Dependencies:
- Phase 0 green (replay EQ-1…EQ-5 pass on ≥ 50-session corpus; fault-injection clean).
- P3 (blocking) — separate GCP project +
GEMINI_API_KEY_CONTINUOUS+ measured RPM/TPM quota; shadow ~2× Gemini load, unsafe without it. - P2 — fixed
GEMINI_FLASH_PRICING+ timeline/tags tokens incost_summary, so G7 cost is trustworthy. - P4 — private ACL on
traces/continuous/**(from Phase 0). P1/P5 are not Phase-1 deps.
Assumptions:
- Submit → DAG untouched; batch is sole author of every prod artifact.
PROMPT_VERSIONpinned identical (2.6.0) across batch + shadow all soak — any bump resets the EQ clock.- Prod chunk keys/ordering match the documented shape (
screensharesession_prefix, per-task{i}_{task_id}folders); S3 doesn't delete chunks mid-soak. - Prod volume is low (~1 concurrent Fargate), so ≥ 200 sessions may need the full ramp and run past one week — G1 needs both, whichever lands later.
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_analysisstays byte-identical (R-SHAPE) — no new keys, noschema_versionbump. 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_fargateDAG withdag_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 copy → screenshare_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.
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-1698runs after it), so a no-op hop will not build the combined MP4 — the service must producescreenshare_videoitself via incremental-c copyconcat at drain. Proctor concat has no sentinel, soproctor_videois 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+ apipeline_metadata.continuousblock alongsideresult_analysis, not inside it. No newresult_analysiskeys, noschema_versionbump, 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)
- Chat send = first authenticated /v2 endpoint; wires the candidate-JWT TODO at
fastapi_service/routers/v2/sessions.py:28. Next attaches the HttpOnlycandidateLoginTokenas a Bearer header, like the flask-proxy. - Task screen: mount
<BarRaiserChatDock/>besideScreenShareDialoginTaskQuestion.tsx(after:1087) — task-only, inheritsactiveTaskId. - Position flag read mirrors
positions.proctor_disabled(ProctorTusRecorder.tsx:64-66; add field atsrc/types/Position.ts:86-87). - Realtime: the browser Supabase client is already tuned for far-from-Mumbai candidates (
timeout: 30_000,heartbeatIntervalMs: 25_000,client.ts:24-28). Per R-REALTIME the analyzer sends a service-role broadcast onchat:{tasksession_id}after each insert (notpostgres_changes); history/reconnect uses the authed Next route. - Outbox copies the WhatsApp inbound loop (
notifications/core/inbound_listener.py, migration20260517000001), re-keyedphone_number→tasksession_id. - Scoring base blocks (
prompts/v2/base_blocks.py, composed atpipeline.py:681-706) gain the 2.7.0 panel note; lands with the FE panel. - P1 fills the dead
{skills_to_be_checked}placeholder (v1/skill.py:46-47) into the awareness plane only — never scoring (R-AWARE-SCORING).
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
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).
- Dashboards — nudge send rate + SILENT-reason mix; guardrail veto rate (the leakage tripwire); chat p50/p95 +
policy_actionmix; spend vs $2.50; L1/L2 coverage + freshness lag. Nudged vs un-nudged cohorts stay separable by report badge; the ≥300 corpus is re-scored on every prompt change. - Fail-closed guardrail — deterministic lint + a separate Flash veto turn that sees only task title/outcomes + pending message + proposed reply (never session content, so it can't be socially engineered); any error drops the message. A lost nudge is recoverable; a leaked solution is not.
- Capability isolation —
tasks.solutionsis never selected into any awareness context (canary-string unit test). A perfect jailbreak leaks only what the candidate already has. - Freshness/gap gates — SILENT (audited) if newest L2 is older than 2×L2 or coverage <60%; force SILENT if >40% gaps. Placeholders read "unknown," never "idle."
- Cost + degradation — at $2.50 or a 429/spend breaker trip: suspend nudges/chat → widen L1 → batch fallback; chat shows "your reviewer has stepped away," still persists (R-OPS).
- Never writes
result_analysis— can't corrupt the report or trip the Gemini sentinel (R-IDLE). - Kill switches (each fire-drilled once):
FF_BAR_RAISER_NUDGES,FF_CANDIDATE_CHAT,positions.nudges_enabled, the Gemini 429/spend breaker, the concurrency clamp.
7h. Dependencies + assumptions
- P1 (position skill scopes) — blocking. The Bar Raiser can't judge "wrong direction for this position" without it; awareness plane only.
- Phases 0–2 complete — skeleton, lease/CAS/heartbeat, chunk ledger + 60s S3 sweeper, live scoring already primary.
- P3 (separate GCP +
GEMINI_API_KEY_CONTINUOUS) — awareness calls off the batch/proctor quota bucket. - Recommended first — TUS hook bearer + pre-create validation (F1.8); re-enable FE screenshare drain/crash recovery (§8.5) to shrink terminal-gap tails. Prompt 2.7.0 ships with the chat panel.
- Assumptions — Gemini ~20MB inline cap holds, keeping L1 at 60s single-turn on inline bytes (validated in Phase 0), and that fidelity is enough for nudge signal. Anon-key access is acceptable via service-role tables + broadcast + authed history route; per-candidate RLS hardening is deferred. Scoring keeps
timeline_source=cvfor byte-identical parity; the L1 swap (timeline_source=l1) is a gated accuracy change out of scope. Design-partner positions exist for opt-in;change_densitystruggle floors are calibrated from a prod histogram pre-launch; consent copy is legal-approved and the no-penalty invariant is stated to candidates.
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:
- Awareness ingest — L1/L2 rows on
analysis_windows,session_digests(CAS), flag columns, coverage observability. Soaks on the replay harness first. - Bar Raiser nudge engine —
candidate_nudges, gate → judgment → guardrail, persona1.0.0, nudge half of the addendum. Owns the ≥300 gate. - Candidate chat —
task_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 apipeline_metadata.continuousJSONB stamp. All go throughsupabase migration new.
8.1 Master table: entity × phase
Every table keys on (tasksession_id, task_id). tasksession_id → task_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_id → candidate_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_indexisfloor((chunk_num − task_anchor) / 6)per(task_id, session_prefix), neverfloor(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 treatingwindow_indexas per-task footage (segment boundaries, nudge lookback, the digest fold).task_anchoris aMINover persisted rows, so a late or replayed event cannot shift it. Same foranalysis_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:
session_digests— versioned with invariantversion = length(applied_l2); CAS foldversion = :expected AND NOT (applied_l2 @> :l2_id).candidate_nudges— audit ledger; every evaluation writes a row (suppressed included), keyedUNIQUE(…, evaluation_key)withevaluation_key = 'l2:{index}', one decision per L2 tick.task_session_chat_messages—task_id NOT NULL; partial-uniqueclient_msg_id(only candidate-sent rows carry one);nudge_id → candidate_nudges ON DELETE SET NULLso the transcript outlives a ledger purge.analyzer_outbox— structural copy of the notifications outbox (20260320062639…:77-104), no FKs.
ALTER TABLE positions ADD COLUMN nudges_enabled boolean NOT NULL DEFAULT false;
ALTER TABLE task_sessions ADD COLUMN nudges_active boolean NOT NULL DEFAULT false;
Decision.
nudges_activeis stamped frompositions.nudges_enabledat session creation, not read live — copying thepositions.scoring_versionseam (20260610000000→20260621054203). 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 joiningpositionsevery 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 tocontinuous_shadow_runs+ S3 only; a canary assertion refuses to build anupsert_task_analysis_entrycall 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.
- Derivable, not enqueued. Window readiness is recomputed from the ledger (
analysis_chunks+analysis_windows), never a queue; any worker after any restart derives the same READY set. TUS hooks are fire-and-forget hints (≤250 ms, R-OPS); the 60 s S3 LIST sweeper is the guaranteed path. - Checkpoint = digest version. Resume pointer is
session_digests.version(monotonic, CAS-advanced,= len(applied_l2)); a restart rebuilds from the last window + digest, andlease_epochfencing keeps zombie writes out. - Paced backfill. A PARTIAL session gets a bounded backfill (≤
MAX_L1_BACKFILL_AT_FINALIZE=8windows) from raw chunks before escalating; a DEGRADED session (coverage < 90%, gap > 5 min, orBATCH_ONLY) goes straight to whole-session batch.
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 specify → plan → tasks 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.mdunchanged.
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)
- Every phase can start
specifycold — no back-reference to a decision that was never made. - Each phase section carries scope (operator value), a build list seeding FRs, a data-model delta naming Key Entities, a measurable exit gate → Success Criteria, and an explicit spec boundary.
- Data model across phases pins which phase owns which table; the Shared mechanisms primer lets specs cite CAS / lease + fencing / two-runners by name; this section fixes feature count and boundaries.