01-projects/printables-product

Scribble Works review portal — technical spec

2026-09-06·spec·status: proposed — ready to build, one open item·by gpt-5.6-sol (Codex/Sol), dispatched via ask-model.sh; spec-first per founder direction after tonight's evaluator-miss incident
scribble-worksreview-portaladminhlrfspec

Founder-requested spec for a second-set-of-eyes review portal, prompted directly by tonight's incident: an AI-generated coloring-page image with an anatomical defect (an extra leg) passed both the automated evaluator and Ray's own visual check, caught only when the founder looked closely at the printed page. Dispatched spec-first rather than build-first, deliberately — the whole point of this feature is a real human review gate, so it shouldn't itself be built by skipping review.

Independently fact-checked by a fresh-eyes pass before filing: all 8 of the founder's settled rulings score PASS or a well-flagged PARTIAL (never silently resolved), and 3/3 spot-checked code citations (file:line references into the actual repo) were accurate, not fabricated.

Open item #3 (§16, catalog-count discrepancy) RESOLVED by founder ruling, 2026-09-07 01:43 ET: "The review website shouldn't matter if I had one game to review or fifty games to review. It's the same process either way. List out all the games for me to review, allow me to click in for the detailed view. I give the decision and fill in a commentary box. You record the decision and make changes as necessary." The portal is scale-agnostic by design — list whatever needs reviewing (any count), no calibration-batch-size question to resolve. This is fully consistent with the spec's own status-driven importer (§3) and the queue/detail/decision flow (§7) as already written — no spec changes needed, just confirms the existing design over-thought a non-issue.


Scribble Works review portal — technical specification

Status: Proposed
Date: 2026-09-06
Owner: Scribble Works
Scope: Specification only; no implementation is included in this document.

1. Summary

Build a second Cloudflare Pages site, scribble-works-review, in this monorepo. It is an authenticated, human review gate for new studio games and UGC-derived catalog variants. It is not a route on the parent-facing site and it does not use the Cloudflare Access policy used by HQ.

An artifact may enter the parent-facing catalog only when both of these facts are true for the same immutable artifact version:

  1. the existing agentic critic gate passed; and
  2. one identity with the platform-level reviewer role explicitly chose Good in the portal.

There is no timeout and no auto-approval. A pending item remains pending until a reviewer chooses Good, Iterate, or Bad. The portal supplements the critic; it never bypasses or replaces it.

The incident motivating this work is the extra-leg defect in an AI-generated coloring page that passed the evaluator and a visual review. Accordingly, the primary review surface is the real rendered, printable page, not a catalog thumbnail or source HTML.

2. Settled product requirements

The following are requirements, not design questions:

3. Existing system facts

These details constrain the design:

At the reviewed origin/release commit (ad65cac), the repository has 53 game directories: 31 live, 22 pending-review, and 0 retired. This does not match the stated “50+ live” count. The calibration importer must derive its input from status at execution time and print a reconciliation report rather than hard-code either number.

4. Architecture

4.1 Monorepo and Cloudflare resources

Add an independently built app at review-portal/:

review-portal/
  package.json
  astro.config.mjs
  src/pages/
  src/components/
  functions/
  public/_headers
  public/_routes.json
migrations-review/
wrangler-review.toml

The second Pages project has its own build command/output, deployment history, custom domain, CSP, and Functions routing. Shared auth primitives remain in src/lib/auth/ and are imported rather than copied. Review-specific data access belongs under review-portal/src/lib/ so that src/lib/auth/store.js remains the auditable list of existing account-auth statements (src/lib/auth/store.js:1-16).

Bindings:

Binding Resource Access Purpose
ACCOUNTS existing production scribble-works-accounts D1 read/write auth; read platform roles magic links, sessions, identities, platform_roles
REVIEWS new scribble-works-reviews D1 read/write queue, decisions, delivery outbox
REVIEW_ARTIFACTS new private R2 bucket read/write for ingestion; read for portal immutable pre-publication PDFs, page previews, manifests
CATALOG_R2_BASE existing public paper-playground URL read-only HTTP source for snapshotting the live calibration batch

Use a new review D1, not ACCOUNTS or UGC:

Use a private review-artifact bucket rather than placing unapproved pages at guessable paths in the public catalog bucket. The portal may read the existing public bucket when importing a live artifact, but it snapshots the bytes under an immutable review key before review. The R2-binding failure is specific evidence from the main Pages project, so deployment of the second project must prove the binding on a preview deployment before production. If that guard fails again, the fallback is a tiny review-artifact Worker with authenticated service-to-service fetch; do not make unapproved objects public.

4.2 Trust boundaries

5. Artifact identity and the two-gate invariant

Approval must attach to what the reviewer actually saw. A slug or Git SHA alone is insufficient.

For every candidate, scripts/review-artifact.mjs will create a canonical manifest containing:

item_version is sha256:<hex> over that canonical manifest. Excluding status lets the exact approved artifact move from pending-review to live without changing its version. The raster hash binds approval to the visible printed output and is the relevant protection for defects such as an extra limb. The PDF and preview copied to R2 live at reviews/<review-id>/<item-version>/game.pdf and page-1.png; keys are immutable and never overwritten.

The ingest API accepts only critic_verdict: "pass", a critic run identifier, completion time, model/agent identifier, a bounded structured summary, and evidence references. It rejects missing or non-pass critic results. A Good receipt later contains the review ID, artifact version, critic-pass fact, human decision, and portal signature. Receipts use an Ed25519 detached signature: the signing key is a portal secret and the verification key is committed for offline CI validation. A new scripts/validate-review-approval.mjs build gate recomputes the version and refuses a new/changed status: live item unless its signed receipt says Good for that exact version. This gate is added to both scripts/preflight.mjs and .github/workflows/ci.yml, whose gate lists are intentionally duplicated (scripts/preflight.mjs:12-42, .github/workflows/ci.yml:1-11).

Existing live games are temporarily grandfathered only during calibration. The enforcement gate becomes required after every then-live game has a portal decision and committed receipt, or has been retired.

6. Data model

All timestamps are epoch milliseconds UTC. IDs are lowercase, time-sortable 26-character IDs matching the existing newId() convention (src/lib/auth/tokens.js:53-63). JSON columns are parsed and shape-validated on every read/write; malformed stored JSON fails the affected request closed.

6.1 Accounts migration: platform roles

Add migrations-accounts/0010_platform_roles.sql:

CREATE TABLE IF NOT EXISTS platform_roles (
  identity_id TEXT NOT NULL,
  role        TEXT NOT NULL
              CHECK (length(role) BETWEEN 1 AND 64 AND role NOT GLOB '*[^a-z0-9_-]*'),
  granted_by  TEXT NOT NULL,
  granted_at  INTEGER NOT NULL,
  revoked_by  TEXT,
  revoked_at  INTEGER,
  PRIMARY KEY (identity_id, role),
  CHECK ((revoked_at IS NULL AND revoked_by IS NULL)
      OR (revoked_at IS NOT NULL AND revoked_by IS NOT NULL))
);

CREATE INDEX IF NOT EXISTS platform_roles_active
  ON platform_roles (role, identity_id, revoked_at);

The four founder-ruled fields are present; revoked_by/revoked_at allow removal without deleting the grant record. role is intentionally not constrained to only reviewer, so future platform roles require no table redesign. There is no reviewers table and no capabilities column: “reviewer” is the query role = 'reviewer' AND revoked_at IS NULL.

granted_by and revoked_by contain an identities.id, not an adults.id. The one bootstrap grant is an operator-run insert after the founder has verified his email; it may self-reference the founder identity. The founder identity then grants the other two roles after their identities exist. No household lead/adult role confers portal access.

6.2 Review database migration

Add migrations-review/0001_reviews.sql:

CREATE TABLE IF NOT EXISTS reviews (
  id                       TEXT PRIMARY KEY,
  item_kind                TEXT NOT NULL CHECK (item_kind IN ('game', 'ugc_variant')),
  item_ref                 TEXT NOT NULL,
  item_version             TEXT NOT NULL,
  source                   TEXT NOT NULL CHECK (source IN ('studio', 'ugc')),
  parent_item_ref          TEXT,
  ugc_customization_id     TEXT,
  source_revision          TEXT NOT NULL,
  catalog_status_at_enqueue TEXT NOT NULL
                             CHECK (catalog_status_at_enqueue IN ('live', 'pending-review', 'retired')),
  was_live_at_enqueue      INTEGER NOT NULL CHECK (was_live_at_enqueue IN (0, 1)),

  critic_run_id            TEXT NOT NULL,
  critic_actor             TEXT NOT NULL,
  critic_completed_at      INTEGER NOT NULL,
  critic_gate_state        TEXT NOT NULL CHECK (critic_gate_state IN ('passed', 'legacy_unknown')),
  critic_passed            INTEGER NOT NULL CHECK (critic_passed IN (0, 1)),
  critic_context_json      TEXT NOT NULL,

  artifact_manifest_json   TEXT NOT NULL,
  render_sha256            TEXT NOT NULL,
  pdf_r2_key               TEXT NOT NULL,
  preview_r2_key           TEXT NOT NULL,

  state                    TEXT NOT NULL DEFAULT 'pending'
                             CHECK (state IN ('pending', 'good', 'iterate', 'bad')),
  note                     TEXT,
  reviewer_identity_id     TEXT,
  enqueued_at              INTEGER NOT NULL,
  decided_at               INTEGER,
  updated_at               INTEGER NOT NULL,

  UNIQUE (item_kind, item_ref, item_version),
  CHECK ((state = 'pending' AND reviewer_identity_id IS NULL AND decided_at IS NULL AND note IS NULL)
      OR (state <> 'pending' AND reviewer_identity_id IS NOT NULL AND decided_at IS NOT NULL)),
  CHECK (state <> 'iterate' OR length(trim(note)) BETWEEN 1 AND 4000),
  CHECK ((critic_gate_state = 'passed' AND critic_passed = 1)
      OR (critic_gate_state = 'legacy_unknown' AND critic_passed = 0 AND was_live_at_enqueue = 1)),
  CHECK ((source = 'ugc' AND item_kind = 'ugc_variant' AND parent_item_ref IS NOT NULL
                         AND (ugc_customization_id IS NOT NULL
                              OR (critic_gate_state = 'legacy_unknown' AND was_live_at_enqueue = 1)))
      OR (source = 'studio' AND item_kind = 'game' AND ugc_customization_id IS NULL))
);

CREATE INDEX IF NOT EXISTS reviews_queue
  ON reviews (state, enqueued_at, id);
CREATE INDEX IF NOT EXISTS reviews_item_history
  ON reviews (item_kind, item_ref, enqueued_at);
CREATE INDEX IF NOT EXISTS reviews_reviewer
  ON reviews (reviewer_identity_id, decided_at);

CREATE TABLE IF NOT EXISTS review_jobs (
  id             TEXT PRIMARY KEY,
  review_id      TEXT NOT NULL,
  action         TEXT NOT NULL CHECK (action IN ('publish', 'iterate', 'unpublish')),
  state          TEXT NOT NULL DEFAULT 'queued'
                   CHECK (state IN ('queued', 'claimed', 'done', 'failed')),
  attempt_count  INTEGER NOT NULL DEFAULT 0,
  last_error_code TEXT,
  claimed_at     INTEGER,
  completed_at   INTEGER,
  created_at     INTEGER NOT NULL,
  updated_at     INTEGER NOT NULL,
  UNIQUE (review_id, action)
);

CREATE INDEX IF NOT EXISTS review_jobs_work
  ON review_jobs (state, created_at, id);

reviews is one immutable-version review attempt. Its only state transition is pending -> good|iterate|bad. Decisions are never edited or deleted. An Iterate result produces a new artifact version and therefore a new review row after the studio finishes the next round. If a correction is ever necessary, enqueue a new row and link it in the artifact context; do not rewrite history.

The decision handler uses a conditional update (... WHERE id = ? AND state = 'pending') and inserts the corresponding outbox job in the same database transaction. The first concurrent reviewer wins; the other receives 409 already_decided plus the winning public decision metadata.

6.3 UGC provenance

Do not put a parent email, household ID/hash, name, or raw ask in the review database. For a UGC candidate, ingestion records:

When the studio accepts a UGC candidate for a build, it also sets that row's existing promoted_slug to the variant slug. This is provenance, not publication: the variant still starts pending-review and must pass both gates. Promoted rows already survive the UGC retention sweep (workers/retention-sweeper/src/sweep.js:12-14,76).

7. State and side effects

Decision New/non-live item Item already live Outbox action
Good eligible to change pending-review -> live calibration sign-off; no content change publish only when not already live
Iterate remains pending-review; revised version gets a new review currently deployed version remains live while the revision stays on a work branch iterate
Bad change to/remain retired; never publish change live -> retired and deploy removal unpublish

The portal must distinguish “decision recorded” from “catalog change deployed.” The review row is terminal immediately; the side-effect chip shows queued, claimed, done, or failed from review_jobs.

7.1 Good -> publish

The local scripts/review-sync.mjs worker polls authenticated job endpoints, verifies the portal-signed receipt, checks out the exact candidate revision in a fresh worktree, recomputes item_version, and refuses any mismatch. For a publish job it:

  1. writes the signed receipt under content/review-receipts/<review-id>.json and adds a review_id pointer to the game's meta.yaml;
  2. changes only status: pending-review to status: live;
  3. renders again and requires the canonical manifest and render_sha256 to match what the reviewer saw;
  4. uploads games/<slug>.pdf plus the generated previews using the existing render/R2 runbook;
  5. regenerates the tracked catalogs/previews and runs all gates; and
  6. opens a narrowly scoped PR to release (or updates the artifact's existing release PR).

It does not merge release -> main or deploy production. Those remain the founder's existing ship step. “Good” is therefore the permission to publish, not a bypass around release control.

7.2 Iterate -> studio dispatch

The current automatic seam is machine-local, not a web API. scripts/ugc-classify.mjs:18,128-159 writes ~/.claude/state/studio/queue.md, and the scheduled 04:41 studio round reads that file (~/.claude/scripts/scheduled-jobs.txt:131-154). ~/.claude/scripts/ask-model.sh:1-44 is a confined model-delegation wrapper; it is not itself a persistent queue consumer or build orchestrator.

Consequently v1 uses this honest bridge:

  1. Portal writes an iterate outbox job containing only the review ID.
  2. scripts/review-sync.mjs, run immediately by an operator or just before the nightly studio round, claims the job and writes a JSON sidecar to ~/.claude/state/studio/review-iterations/<job-id>.json. The sidecar contains item/version, live-at-decision flag, reviewer note, artifact/critic references, and source revision.
  3. It adds one idempotent queue line under ## Review portal iterations (auto) referencing that sidecar and review ID. The reviewer note is data, not shell or prompt syntax; it is never interpolated into a command.
  4. The normal studio round dispatches the matching creator agent, which may invoke ask-model.sh through the existing harness, runs the same render and critic gates, and calls review:enqueue for the new version after PASS.
  5. Only then is the iterate job marked done. A failed build or critic result remains attached to the studio round; it does not create a review row because it did not clear gate one.

This is automated from portal decision to queue entry once review-sync runs. It is not immediate cloud-to-laptop execution: the actual build waits for the operator or the scheduled studio round. V1 must say that in the UI (“Queued for the next studio round”).

For an already-live slug, the studio works on a branch while production continues serving the last approved version. The revised live file must not merge to release/main until its new version receives Good. For a new slug, its metadata remains pending-review throughout iteration.

7.3 Bad -> unpublish/never publish

Games and UGC variants use the same catalog representation, so unpublication is deliberately the same operation:

For a never-live candidate, Bad changes pending-review -> retired (or leaves an already-retired record alone). Do not delete the R2 review artifact. A public games/<slug>.pdf may remain as an unlinked object after retirement, but the same-origin PDF route returns 404 because its live allowlist no longer contains the slug. Bucket-object deletion is a separate retention decision and is not required to unpublish.

Because the catalog is static today, Bad is not globally visible until the removal PR is merged and production is redeployed. review-sync should prioritize unpublish jobs and expose the lag prominently. If urgent, an operator runs it immediately and follows the existing hotfix/release process; v1 must not claim an instantaneous kill switch that the architecture does not provide.

8. Enqueue mechanisms

8.1 Common ingestion command

Add a local command (future implementation):

npm run review:enqueue -- \
  --slug <slug> \
  --revision <git-sha> \
  --critic-run <run-id> \
  --critic-actor <model-or-agent> \
  --critic-evidence <path-or-url> \
  [--ugc-row <customizations.id>]

It must run after render:games and after the artifact critic reports PASS. It builds the canonical manifest, snapshots the exact PDF/preview into private R2, and calls POST /api/internal/reviews. The API recomputes/validates bounded fields, permits only PASS, and idempotently returns the existing row for the same (item_kind,item_ref,item_version).

8.2 Studio-created game

There is no current catalogPublish() function to patch. A game goes live when a metadata edit reaches a production build. Therefore the enqueue seam is the studio/release process, not src/lib/games.js:

  1. New game begins status: pending-review (the documented studio default in src/lib/game-status.js:5-8).
  2. Existing render, smoke, and critic gates run. The current release rule already requires critic gates before merge (docs/ARCHITECTURE.md:381-387).
  3. On critic PASS, the studio round invokes review:enqueue before filing/merging the content PR and records the returned review_id in metadata.
  4. The item may merge to release as pending, but it cannot become live without a Good receipt for the exact version.

Add the enqueue step to the 04:41 studio-round instruction immediately after “gate every artifact” and before “file what passes.” This is the exact operational hook. Do not enqueue from scripts/render-games.mjs; its mechanical page/font checks are necessary but are not the agentic critic gate.

8.3 UGC variant

The existing UGC path has two separate stages and the hook belongs at the second:

  1. functions/api/customize.js writes the sanitized customization row; art/icon endpoints attach hashes. No review is created here because most customizations never become catalog candidates.
  2. scripts/ugc-classify.mjs classifies/deduplicates and appends a candidate build line. No review is created here because there is not yet a printable artifact to inspect.
  3. When the studio builds the selected candidate, it creates a normal content/games/<variant-slug>/ entry with variant_of, origin: customize, and status: pending-review, updates the source row's promoted_slug, then runs render and critic gates.
  4. After critic PASS, it invokes the same review:enqueue command with --ugc-row. The portal snapshots the UGC context and classifies the item as item_kind=ugc_variant, source=ugc.

This keeps the portal a gate on a real printable rather than a voting interface for raw parent prompts.

8.4 Calibration backfill

scripts/review-backfill-live.mjs enumerates loadAllGames().filter(status === 'live'), fetches or renders each live PDF, computes the current version, snapshots it, and enqueues with:

The command defaults to dry-run and prints counts by status/source, missing PDFs/previews, duplicates, and the repository-vs-deployed catalog difference. A separate --apply is required. It is idempotent on item/version. Reviewers work this batch through the same UI; Good creates a receipt, Iterate creates studio work while leaving the current item live, and Bad starts unpublication.

The backfill is the one permitted exception to “ingest requires a recorded critic PASS,” because the artifacts are already live and are being calibrated retroactively. The database constraint and service handler permit legacy_unknown only when was_live_at_enqueue=1; it cannot admit or publish a new item. The same constrained legacy case permits a null ugc_customization_id if an old live variant cannot be joined back to its source row; all new UGC ingestion requires the ID. A calibration Good receipt records the legacy critic state honestly. Any later version must carry an actual critic PASS.

9. Authentication and authorization

9.1 Reuse, do not share cookies

Reuse the functions and contracts from:

The review hostname mints its own host-only __Host-sw_session cookie. A person signed into the catalog must sign into the review portal separately; both sessions are rows in the same production accounts D1 and use the same SESSION_SECRET. This preserves the existing no-Domain cookie guarantee.

The review Pages project sets AUTH_ALLOWLIST to exactly the three reviewer addresses and must fail closed if that variable is absent. This limits who is sent review-host links, but it is not authorization. The database role remains the only access grant.

9.2 Reviewer guard

After getSession() succeeds, requireReviewer() resolves the exact session identity and active role:

SELECT si.identity_id
FROM session_identities si
JOIN platform_roles pr ON pr.identity_id = si.identity_id
WHERE si.session_id = ?
  AND pr.role = 'reviewer'
  AND pr.revoked_at IS NULL
LIMIT 1;

There is no fallback to households.email, identities.household_id, session_adults, or adults.role. If session_identities or platform_roles is unavailable, authorization fails closed with 503; if the session is valid but the role is absent, return 403 and a plain “This account is not a reviewer” page. A revoked platform role takes effect on the next request even if the session remains valid.

Mutating endpoints call isSameOrigin() before session lookup, matching the hardened account API posture. Decision requests include expected_item_version and an Idempotency-Key; the conditional state update remains the final race protection.

9.3 Initial grants

Identity IDs cannot be known until each email has completed verified sign-in. Launch sequence:

  1. Founder signs in through the review host once; the operator grants his identity reviewer with granted_by equal to that founder identity (documented bootstrap).
  2. Wife and mother each complete a first magic-link sign-in and see the no-role holding page.
  3. Founder identity grants their identity IDs via a narrowly scoped operator command, scripts/platform-role.mjs grant reviewer --email <email> --granted-by <founder-identity-id>.
  4. Remove any temporary onboarding-only email configuration; keep the exact three-address AUTH_ALLOWLIST and the three database grants.

V1 has no role-management screen. Grant/revoke is an operator action with tests and an explicit audit row, not a hidden email comparison in application code.

10. API

All JSON errors use { "ok": false, "code": "...", "note": "..." }. All reviewer endpoints require requireReviewer() and Cache-Control: no-store.

Method/path Purpose Important behavior
POST /api/auth/request Send review-host magic link Existing enumeration-safe rail; explicit three-person allowlist
GET /api/auth/callback Inert interstitial Existing implementation
POST /api/auth/confirm Consume link and mint review-host session Existing implementation; redirect /queue/
POST /api/auth/signout Revoke session/clear cookie Existing implementation
GET /api/me Reviewer session summary {signed_in, reviewer, identity_id}; do not return household data
GET /api/reviews Paginated queue/history Filters: state, source, item_kind, live, q; cursor (enqueued_at,id); default state=pending
GET /api/reviews/:id Detail/context/history Includes critic and sanitized UGC context, job status, prior versions
GET /api/reviews/:id/pdf Exact reviewed PDF Streams immutable private R2 object; supports GET/HEAD and byte ranges if runtime permits
GET /api/reviews/:id/preview Page-one list preview Streams immutable PNG
POST /api/reviews/:id/decision Good/Iterate/Bad Body below; one terminal transition; creates outbox job when needed
GET /api/exports/hlrf.jsonl Download decisions Filters after, before, source, decision; deterministic (decided_at,id) order
POST /api/internal/reviews Critic-passed ingestion Service-secret only; idempotent item/version insert
GET /api/internal/jobs Poll queued jobs Service-secret only; claim lease and bounded batch
POST /api/internal/jobs/:id/complete Ack/fail a job Service-secret only; structured error code, retryable flag

Decision body:

{
  "decision": "good",
  "note": null,
  "expected_item_version": "sha256:..."
}

decision is exactly good|iterate|bad. note is required and 1-4,000 trimmed characters for Iterate; it is optional (same cap) for Good/Bad. Unknown fields are rejected. Return 200 with the terminal review and job state; return 409 for version mismatch or an already-decided item.

11. Screens

The portal has exactly three product screens plus the necessary sign-in/holding pages.

11.1 Queue

Route: /queue/

Desktop rough layout:

+--------------------------------------------------------------+
| Scribble Works Review        Pending 12       [Export HLRF]  |
+--------------------------------------------------------------+
| [Pending] [Good] [Iterate] [Bad]   Source [All v]   Search   |
+--------------------------------------------------------------+
| preview | Title / type / source | critic pass | age | queued |
| preview | Title / type / source | critic pass | age | queued |
| preview | Title / type / source | critic pass | age | queued |
+--------------------------------------------------------------+

11.2 Item detail

Route: /reviews/<id>/

The left/main column is an embedded actual PDF from /api/reviews/<id>/pdf, fit to page with zoom, next-page controls if the renderer ever permits multiple pages, and an explicit “Open full-size / Print proof” action. The default view is at least 816 CSS pixels wide on desktop when space permits. Do not substitute GamePreview.astro: it is a catalog raster/placeholder component (src/components/GamePreview.astro:1-21), while this gate must show the exact reviewed printable.

The right column shows:

On narrow screens the PDF comes first, then metadata, then a sticky “Decide” action. The browser's native PDF renderer is acceptable; the system reuses the existing PDF render artifact and proxy-streaming pattern rather than inventing a canvas renderer.

11.3 Decision UI

Route: /reviews/<id>/decide/ (also reachable as a sheet/dialog from detail, but it has a real route for reload/accessibility).

12. HLRF JSONL export

The export is a point-in-time stream with one UTF-8 JSON object per terminal review row, newline terminated, ordered by (decided_at,id). schema_version starts at scribble-works.hlrf-decision.v1. Additive fields may be introduced in v1; breaking changes require v2.

Exact record shape:

{
  "schema_version": "scribble-works.hlrf-decision.v1",
  "event_id": "01...",
  "review_id": "01...",
  "decision": "iterate",
  "note": "The child has an extra left leg; regenerate the figure and verify limb count on the printed page.",
  "decided_at": "2026-09-06T22:14:31.442Z",
  "reviewer": {
    "identity_id": "01..."
  },
  "artifact": {
    "kind": "game",
    "source": "studio",
    "ref": "color-the-soccer-star",
    "version": "sha256:...",
    "source_revision": "<40-char-git-sha>",
    "parent_ref": null,
    "was_live_at_enqueue": false,
    "catalog_status_at_enqueue": "pending-review",
    "render_sha256": "sha256:...",
    "files": [
      {"path": "game.html", "sha256": "..."},
      {"path": "art.jpg", "sha256": "..."},
      {"path": "slots.json", "sha256": "..."}
    ],
    "metadata": {
      "title": "Color the Soccer Star",
      "description": "...",
      "language": "en",
      "taxonomy": {
        "age_band": "5_6",
        "skill": "art_drawing",
        "skill_secondary": null,
        "activity": "coloring",
        "theme": ["sports"],
        "materials": ["crayons"],
        "flags": ["low_ink"]
      }
    }
  },
  "critic": {
    "gate_passed": true,
    "run_id": "...",
    "actor": "claude-sonnet-5-vision",
    "completed_at": "2026-09-06T20:02:04.000Z",
    "verdict": "pass",
    "labels": [],
    "summary": "...",
    "evidence_refs": ["..."]
  },
  "ugc_context": null,
  "delivery": {
    "action": "iterate",
    "state": "queued"
  }
}

For a UGC variant, artifact.kind="ugc_variant", artifact.source="ugc", and ugc_context is:

{
  "customization_id": "01...",
  "parent_game_ref": "color-the-dino",
  "language": "es",
  "age_band": "3_4",
  "theme_tags": ["unicorn", "rainbow"],
  "demand_score": 3,
  "values": {"title": "...", "instruction": "..."},
  "params": {},
  "changed_slots": ["title", "instruction", "art:dino"],
  "declined_slots": [],
  "art_content_ids": ["<sha256>"],
  "icon_content_ids": []
}

Why these fields exist:

Privacy boundary: export no reviewer email, adult/household record, household hash, IP, user-agent, child name, raw magic-link/session material, or raw parent ask. Structured sanitized values already permitted in the promoted variant are sufficient. The export endpoint performs JSON encoding field-by-field; it does not serialize database rows wholesale.

13. Failure behavior and observability

14. Verification plan

Minimum implementation gates:

  1. Apply accounts, UGC, and review migrations separately to empty SQLite databases; extend scripts/test-migrations.mjs, which currently asserts the accounts/UGC split (:9-46).
  2. Auth tests: no cookie, expired/revoked session, absent role, revoked role, household lead without reviewer role, reviewer identity in any household, missing tables/bindings, cross-origin mutation, and host-only cookie behavior.
  3. Decision tests: all three transitions, Iterate note rules, unknown fields, version mismatch, concurrent decisions, idempotency replay, and atomic outbox creation.
  4. Ingest tests: critic non-pass rejected; duplicate version idempotent; changed visible asset creates a new version; status-only change does not; oversized/malformed context rejected; UGC provenance rules enforced.
  5. Artifact tests: actual PDF bytes streamed only to a reviewer; immutable key mismatch fails; page raster hash changes when art changes.
  6. Approval gate tests: pending artifact can build but cannot leak into dist; live transition without receipt fails; receipt for another version fails; forged signature fails; exact Good receipt passes; Iterate/Bad receipt cannot publish.
  7. Side-effect tests: Good new -> publish job, Good live -> no content mutation, Iterate preserves live/pending status, Bad -> retired, retries are idempotent.
  8. HLRF schema fixture tests and a privacy deny-list scan for email, household hash, raw ask, IP, cookie, and session fields.
  9. Headless smoke at desktop/mobile for sign-in, queue filters, exact PDF detail, all decision branches, conflict state, and next-item navigation.
  10. Preview-deployment guard proving the second Pages project's D1 and private R2 bindings before production.

15. Rollout

  1. Create review D1/private R2 and preview Pages project; apply only preview migrations.
  2. Implement and test shared magic-link imports and requireReviewer; prove no Cloudflare Access dependency.
  3. Grant founder preview role, then wife/mother; run the three-person auth smoke.
  4. Run calibration backfill dry-run and reconcile repository, R2, and deployed-catalog counts.
  5. Apply the calibration import. Review all then-live items. Immediately process any Bad jobs; Iterate jobs enter the normal studio queue.
  6. Commit Good receipts/retirements, then enable the mandatory signed-receipt build gate.
  7. Add review:enqueue to the studio round after critic PASS, and review-sync immediately before the nightly queue is consumed.
  8. Prove one studio game and one UGC variant end to end on preview: critic PASS -> pending portal -> human Good -> receipt/status PR -> release build; also prove an Iterate and Bad path.
  9. Bind production accounts/review databases and artifact bucket, set exact allowlist/secrets, deploy the separate production hostname, and repeat auth/security smoke.

16. Genuine open questions

  1. Production hostname: choose the exact unlinked host (for example review.scribbleworks.co). This does not change the separate-site requirement.
  2. Initial identity mapping: provide/confirm the three normalized email addresses, or the resulting identities.id values after first sign-in, so the platform-role grants can be made to the correct people.
  3. Catalog count reconciliation: the reviewed release snapshot has 31 live and 53 total games, while the requirement says 50+ live. Before applying the baseline import, determine whether production is ahead of origin/release, or whether “50+” meant all current game records. The importer remains status-driven either way; the founder ruling to calibrate every actually-live item is unchanged.

Everything else above is an implementation decision within the settled founder requirements.