Live Cast core tables and admin API

Task 6089 · Done · Coder · created · updated

Live Cast epic — child C2 (all Live Cast tables, slug, shas, the ONE visibility predicate, admin + override API, quota). Source documents: PRD = Task 6085, Architecture = Task 6086 (§2 in full). Depends on C1's `live_cast.entitlement.entitlement()`. ## Original requirement (verbatim, epic body) - "[live.jaah.ai/{org_slug}/{project_slug}] - project slug is a sha hash (6 symbols) of project_id" (A4) - "Live Cast checkbox. Initial value: off" (A9); "Publish by default: All/None" (A10); "Show only/Hide those that: a list of rules (prompt) to evaluate against each task and each artifact in order to show it (default None) or hide it (default All)" (A11); "Publish / Unpublish button - publishes the live cast" (A12) - "Total of Live Cast cannot exceed the number allowed (plan + Operator overrides)" (A13) - "When Publish is clicked - the default and show only, etc. are frozen, their sha is calculated and recorded" (A14) - "Org Admin may override published state and it cannot be changed automatically until Admin resets his change." (A18) Decisions: D-5 quota counts published projects; D-7 override on the task page (task + each artifact) with Reset; D-11 locked while published, unpublish to edit; D-12 unpublish → viewer 404 immediately, seat freed, evaluation marks and overrides kept. ## Acceptance owned here (PRD, verbatim; API halves) - AC1.6 (API half). An org with zero Live Cast allowance, or none at all, never sees the Live Cast tab, and the API refuses its Live Cast writes with 403 or 409. The UI-gated state and the API-gated state must agree. - AC2.1. Only published projects consume quota. Ticking the checkbox alone does not. (allowance 1: tick two, both saves succeed; publish first ok; publish second refused "Live Cast limit reached: 1") - AC2.2. Unpublishing frees a seat immediately. - AC2.3. If the allowance drops below the published count (a downgrade), no new publish is accepted until the count is under the limit. Already-published projects keep serving; nothing is auto-unpublished. - AC2.4. The limit check is race-safe: two concurrent publishes cannot exceed the limit. - AC3.1 (API half). A non-admin member … the API refuses the member's writes with 403. - AC3.3 (API). While published, the default and the rules are read-only (D-11). - AC3.4. On Publish, the default and rules are frozen and a rules sha over their canonical form is computed and stored. Identical settings give an identical sha. - AC5.1 (API half) / AC5.2. An overridden item is never changed automatically. Re-evaluation, rule changes, a body edit, an outcome change and republishing all leave it as set, until the admin presses Reset. - AC6.2 (logic). project_slug is the first 6 hex chars of a sha hash of project_id; unique within an org, collision handled deterministically by extending; unknown, unpublished, or product-lapsed org/project paths resolve to nothing (→ 404 in C5). ## Approach (architecture §2, pinned) Tables (ONE migration — the only one in phase 2; it also creates C5's token table so phase 3 carries at most one migration): ```sql live_cast_projects(project_id int PK FK projects ON DELETE CASCADE, org_id uuid NOT NULL, enabled bool NOT NULL DEFAULT false, default_mode text NOT NULL DEFAULT 'all' CHECK IN ('all','none'), rules jsonb NOT NULL DEFAULT '[]' -- list[str], each 1..500 chars, max 20, slug text NOT NULL, published_at timestamptz NULL, rules_sha text NULL, backfill int NULL, + EntityMixin audit columns, UNIQUE(org_id, slug)) live_cast_items(id bigserial PK, project_id int NOT NULL, org_id uuid NOT NULL, item_kind text NOT NULL CHECK IN ('task','attachment'), item_id int NOT NULL, task_id int NOT NULL, verdict text NOT NULL DEFAULT 'pending' CHECK IN ('pending','published','hidden'), rules_sha text NULL, content_sha text NULL, evaluated_at timestamptz NULL, attempts int NOT NULL DEFAULT 0, last_error text NULL, override text NULL CHECK IN ('published','hidden'), override_by uuid NULL, override_at timestamptz NULL, in_scope bool NOT NULL DEFAULT true, UNIQUE(item_kind, item_id)) live_viewer_tokens(id, token_sha256 UNIQUE, ip_hmac, created_at, expires_at) -- used by C5 only ``` Include every column C3 needs (attempts, last_error, evaluated_at, in_scope) so C3 adds no table/column migration. - Slug: `sha256(str(project_id)).hexdigest()[:6]`, assigned once at row creation (first open/save of the tab), on `UNIQUE(org_id, slug)` conflict extend 7, 8, … chars; stored, never recomputed (AD-4). - Rules sha: `sha256(json.dumps({"v":1,"default":default_mode,"rules":[r.strip() for r in rules]}, sort_keys=True, separators=(",",":")))`; order significant. - Content sha in SQL, pinned in `live_cast/sql.py`: task `md5(coalesce(title,'') || E'\x1f' || coalesce(body,'') || E'\x1f' || coalesce(outcome_summary,''))`; attachment `md5(coalesce(s3_key,'') || ':' || coalesce(size_bytes::text,''))`. Honour: `s3_key` nullable, `uploaded_at` NULL until upload finishes, `task_id` nullable, `visibility` column. - `live_cast.is_text(content_type, name)`: `text/*`, markdown, html, csv, json, and `report`/`prd`/`architecture` kinds — the single text predicate C3 and the visibility clause both call. - PINNED visibility contract (the ONLY decider; C3/C4/C5 consume) — `apps/api/app/live_cast/visibility.py`: ```python def published_project(session, org_slug: str, slug: str) -> LiveProject | None # None ⇒ 404 def visible_tasks_clause(project: LiveProject) -> ColumnElement[bool] def visible_attachments_clause(project: LiveProject) -> ColumnElement[bool] def task_visible(session, project, task_id: int) -> bool ``` `published_project` only when `enabled AND published_at IS NOT NULL`, org active, `entitlement(org).projects > 0`. Task visible iff in project, passes the console list scope (reuse `GET /api/tasks`'s predicate: not operator-hidden / system-internal, AS-7), and (`override='published'` OR (`override IS NULL AND verdict='published' AND rules_sha = project.rules_sha AND content_sha = <current md5> AND in_scope`)). Attachment eligible only when `task_id IS NOT NULL AND uploaded_at IS NOT NULL AND visibility = <public constant>`; visible iff eligible, its task visible (AC4.7), and (non-text with no row or `override IS DISTINCT FROM 'hidden'`) OR (text with its own row satisfying the same conjunction). Fail closed: missing row, pending, stale sha, error ⇒ hidden. Comments/Q&A shown whenever the task is visible (Q-3). Epic children filtered individually. - Admin API (all require `Principal.is_org_admin` or operator on the project's org, `authz.py:819`; else 403; `entitlement.projects == 0` ⇒ 403 "Live Cast is not in your plan"): - `GET /api/projects/{id}/live-cast` → `{enabled, default_mode, rules[], published, published_at, rules_sha, slug, public_url, progress:{evaluated,total}, entitlement:{projects, used}}` (progress: evaluated = in-scope rows with current sha or override; total = in-scope rows). - `PUT /api/projects/{id}/live-cast` `{enabled, default_mode, rules[]}`; 409 while published; `enabled=false` while published also 409. - `POST …/live-cast/publish`: under `org_limits.lock_org` (`org_limits.py:254`) count published; `used >= projects` ⇒ 409 "Live Cast limit reached: {projects}"; else freeze `rules_sha`, `backfill` (= entitlement.backfill), set `published_at`, and call a backfill hook that C3 implements (expose a single no-op-by-default seam, e.g. `live_cast.on_publish(session, project)`, that C3 fills; C2's tests must not need C3). - `POST …/live-cast/unpublish`: `published_at = NULL`; items and overrides kept. - `PUT /api/tasks/{task_id}/live-cast` `{override: 'published'|'hidden'|null}` (null = Reset); upsert row when none (task outside last N gets `in_scope=false, verdict='hidden'`); `override='published'` shows regardless of `in_scope`. - `PUT /api/tasks/{task_id}/attachments/{att_id}/live-cast` same body, upserts for any attachment; Reset on a non-text row deletes it. - `GET /api/tasks/{task_id}/live-cast` → `{enabled, items:[{kind, id, effective:'published'|'hidden', source:'override'|'rules'|'pending'}]}`; `enabled:false` when the project is not enabled. - `public_url` from new setting `LIVE_PUBLIC_BASE_URL`, code default `https://live.jaah.ai` (C7 sets the env per environment). - Every write emits `emit_console_event(session, entity="live_cast", id=project_id, action=...)`; every override change also emits `emit_console_event(entity="task", id=task_id, action="updated")`. Credentials & reach: - AC2.x/AC3.x/AC5.x console-side checks — `jaah/debug/main/operator-login` — verified (roster + `task_credential_get` ok; dev health 200 from h2). - AC2.4, AC6.2 logic, unit/API tests — none beyond the worktree (local `./dc` stack) — verified. Out of scope: - NC-6 (verbatim): "Do not auto-unpublish, auto-delete or auto-change any admin override." - NC-9 (verbatim): "Do not touch projects' existing `name`/id semantics. The project slug is additive." - NC-5 (verbatim): "Do not change existing plan-product kinds' semantics, existing caps (`limit_*`), node bundle reconciliation, or Jaah AI credit." - NC-8: no feature flag that ships it off. - Do NOT build the LLM evaluation, DBOS workflow or sweep (C3), any web UI (C4/C6), public `/api/public/live/*` routes (C5), or terraform/nginx (C7). No second visibility decider anywhere. Done means: - AC1.6 (API) — test: org with entitlement 0 gets 403 "Live Cast is not in your plan" on every admin route. - AC2.1 — API test: allowance 1, PUT enabled on two projects both 200; publish first 200; publish second 409 with detail "Live Cast limit reached: 1". - AC2.2 — continuing, unpublish first then publish second: 200. - AC2.3 — 3 published, allowance reduced to 1: a 4th publish 409; `published_project` still returns the three. - AC2.4 — API test fires two parallel publishes at allowance 1: exactly one succeeds. - AC3.1 (API) — member PUT returns 403; admin and operator succeed. - AC3.3 (API) — PUT while published returns 409; after unpublish PUT 200. - AC3.4 — publish/unpublish/publish unchanged: same `rules_sha`; one rule character changed: different sha (unit test + API read). - AC5.1/AC5.2 (API) — override a task to published, then change its body/rules/republish: `GET /api/tasks/{id}/live-cast` still shows `published` / `override`; Reset returns `source` to rules/pending. - AC6.2 (logic) — unit tests: slug = first 6 hex of sha256(id); a forced collision extends to 7 deterministically; `published_project` returns None for unknown org, unknown slug, unpublished, product-lapsed org; returns the row for an over-quota already-published project. - Visibility predicate unit tests over a mixed fixture: override wins; stale `rules_sha` or `content_sha` ⇒ hidden; pending ⇒ hidden; out-of-scope ⇒ hidden unless override published; text artifact on hidden task hidden; non-text artifact follows its task unless overridden hidden; other projects' tasks never match. - `./dc check full` green.

Outcome

Live Cast tables (one migration), the one visibility predicate, and the admin/override API with a race-safe quota: #5290 merged, full gate green, dev deploy green. GET is read-only, so the slug is stored on first save; is_text now fails closed.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)