Skip to content

feat(pwa-demo): standalone local-first PWA demo with guarded release tooling - #35

Open
johnnyrobot wants to merge 61 commits into
mainfrom
codex/calricula-pwa-demo-release
Open

feat(pwa-demo): standalone local-first PWA demo with guarded release tooling#35
johnnyrobot wants to merge 61 commits into
mainfrom
codex/calricula-pwa-demo-release

Conversation

@johnnyrobot

Copy link
Copy Markdown
Owner

Introduces calricula_pwa_demo/ — a standalone, local-first PWA demo that lives
as a tracked subtree in this repository. 61 commits, 288 files, all additive.

This is source only. Nothing here is deployed, and this PR does not deploy
anything.

What it is

It shares the product domain and visual language with the main app but none of
its stack
. No Next.js server, no FastAPI, no PostgreSQL, no Firebase, no
Gemini, no Docker, no auth, no server database. The root CLAUDE.md does not
apply inside that directory; calricula_pwa_demo/CLAUDE.md governs it.

  • One Worker, static assets first. output: 'export' produces a finite route
    set; run_worker_first: ["/api/*"] means the Worker only ever sees API
    traffic. Entity IDs live in query parameters, which keeps the route set finite
    and precacheable.
  • Local-first data. Dexie/IndexedDB behind a single curriculumRepository
    write path. Nothing leaves the device except the AI calls.
  • Compliance engine. Pure TypeScript for Title 5 hour math, pinned by
    golden-parity.test.ts to a captured fixture of the parent Python compliance
    service — that parity test is the contract.
  • AI path. Same-origin routes behind Turnstile, an HMAC-signed session
    cookie, rate limits, and an authoritative five-per-UTC-day quota in a SQLite
    Durable Object. Free-only OpenRouter routing (ZDR, data_collection: deny,
    zero price caps). The quota DO stores an HMAC-derived install ID, day, attempt
    count, request IDs, and expiry — never prompts, responses, or curriculum text.
  • Guarded release tooling under scripts/ (54 files), most with a sibling
    *.test.mjs. Ships fail-closed: AI_ENABLED=false, an invalid APP_ORIGIN,
    and an empty model list, so an accidental first deploy cannot serve AI.

Verification status

Per calricula_pwa_demo/HANDOFF.md, npm run verify passed at d650b92
617 UI/repository/release-script tests in 79 files, 222 Worker tests, 7 Chromium
smoke tests. I did not re-run the suite for this PR; that figure is quoted
from the handoff, not freshly measured. The two most recent commits are
documentation only.

Release status — not ready, deliberately

  • No Cloudflare Worker bootstrapped; no hostname, version, or rollback receipt
  • .release-evidence/local-gate.json does not exist — the 14-step gate has
    never been run to a seal
  • No Turnstile, OpenRouter, or HMAC credentials configured
  • Live model qualification, production E2E, Lighthouse, offline checks, and both
    AI canaries all still outstanding

docs/handoffs/2026-08-07-release-key-provisioning.md is the starting point for
that work. It maps every key to one of four destinations and stages them in
dependency order, because three of them cannot exist until earlier steps produce
them.

Reviewer notes

  • The Codex Security diff scans have been dropped (the maintainer is no
    longer using Codex). HANDOFF.md still carries two scan rows reading as
    blocking; restating them is the first task recorded in the key-provisioning
    handoff, and is intentionally not done in this PR.
  • Gate step 10, release:fresh-checkout, has never run against the current
    source shape.
    f1e0535 added shared/ without enumerating it as a release
    input, breaking that step for four commits; d650b92 fixed the enumeration
    and added a directory-coverage guard. The repair is proven by its unit test,
    not by an actual rebuild.
  • Scope boundary holds: git diff --name-only main...HEAD -- . ':(exclude)calricula_pwa_demo/**'
    is empty. No file outside the demo directory is touched.
  • No secrets are committed. Credential-shaped literals in scripts/** and
    tests/** are synthetic fixtures, each named to announce it
    (sk-or-v1-not-a-real-key, must-not-reach-a-browser).

🤖 Generated with Claude Code

johnnyrobot and others added 30 commits July 30, 2026 21:23
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Worker sets allow_fallbacks: true; freeness is enforced by the :free
model list and zero max_price, not by disabling fallback. Only the
ai:evaluate script sets allow_fallbacks: false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rule that no secret reaches a child process was written seven times
across the release scripts: four byte-identical 20-key lists, plus three
17-key variants that deliberately retain Cloudflare credentials so
Wrangler can authenticate. Nothing recorded that the shorter lists were
intentional, so each copy read as a possible oversight.

scripts/child-environment.mjs now owns the key list and names the one
axis that varies: childEnvironment() denies every secret, and
wranglerChildEnvironment() keeps exactly the three Cloudflare
credentials Wrangler needs. The functions that add to the scrub
(releaseGateEnvironment, postdeployEnvironment,
wranglerReadOnlyEnvironment) delegate the scrub half and keep their own
augmentation; the pure pass-throughs are deleted.

No behaviour change. The two inline lists omitted WRANGLER_OUTPUT_FILE_PATH
and assigned it on the next line, so scrubbing it first is inert.

Secret scrubbing is now asserted once, in child-environment.test.mjs; the
four test files that duplicated that assertion narrow to what their own
function adds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The build validator, the Worker-bundle validator, and the production
verifier each carried their own copy of the three secret shapes. The
copies had already drifted: only the build validator's patterns were
global, forcing a `pattern.lastIndex` reset in the shared file scanner
so a stateful regex could not skip a later file.

`scripts/secret-scan.mjs` now owns `SECRET_PATTERNS` and the single verb
that answers whether a text carries a secret. All three checkpoints feed
it their bytes; `scanFilesForSecretPatterns` no longer accepts caller
patterns, so no caller can hand it a global regex and the reset deletes
itself. Its test now scans for a real private key block rather than a
stand-in marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guarantee that the demo can only ever reach free, zero-retention
models was asserted in four separate strata of worker/index.ts: the
model-list parser, the provider block built per request, and two
response validators several hundred lines away. Nothing tied them
together, and the model list was parsed twice per request.

worker/free-routing.ts now owns both directions. buildFreeRouting names
the chain and the provider constraints; assertFreeRoutingHonoured
re-checks the model and the reported cost of what came back. The routing
is built once, before the daily quota is reserved, so a misconfigured
model list still fails closed without spending an attempt.

ApiError and the JSON record helpers move to their own modules so the
policy module can throw the same errors without importing the Worker
entry point back into itself.

Provider fallback stays permitted; freeness is enforced by the :free
model list plus a zero max_price. The single-attempt stance was already
guarded by the upstream-error tests and is now stated in the module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wire protocol between the Worker and the DailyAiQuota Durable Object
was written four times: the caller built the request body, the object
re-stated the same field constraints, the caller re-validated the
response shape, and the in-memory test double reimplemented the
reservation decision with the five-per-day limit hardcoded.

worker/quota-protocol.ts now owns the request shape, the response
predicate, and the decision itself. The Durable Object applies that
decision inside a storage transaction; the test double applies it over a
Map. Only the storage differs, so the double can no longer drift from
the object it stands in for.

Making the double faithful surfaced a gap it had been hiding: it never
validated expiresAtMs, so no test exercised the expiry rule the object
actually enforces. The clock is now an explicit parameter of the double
and each suite pins it to the same instant its caller uses.

The object still stores only an HMAC-derived install ID, the day, an
attempt count, request IDs, and an expiry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three AI surfaces each re-derived what a caller must do next. Both
panels read the session marker directly and repeated the rule that a 401
or 403 discards the session; the consent gate derived its stage from the
disclosure flag alone.

src/lib/ai/session-readiness.ts now answers that question once, as
ready / needs-disclosure / needs-challenge, and owns the re-sync rule.
An established session deliberately outranks a cleared acknowledgement,
which preserves the previous behaviour of every caller exactly.

Scoped deliberately short of the architecture review's proposal. That
called for readiness to ask the server rather than remember, but the
authority is an HttpOnly __Host- cookie the browser cannot read and the
only session route requires a Turnstile token, so asking would mean a
new unauthenticated endpoint reporting cookie validity. That is a
security-surface change, not a refactor, and the benefit it buys is one
avoided challenge in a second tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The courses/ area already held this seam: the route screen reads and
writes, and the views below it render from props. Three components
elsewhere reached past it.

ApprovalActionPanel wrote a workflow transition from inside the queue;
ProgramCourseBuilder, a grandchild of the program editor, both read the
course list and wrote requirement reordering; ProgramView read its own
aggregate and references. Each now takes what it needs as props, and the
screen above it owns the repository call.

The cost of the old shape was visible in the tests: all three mocked
../../lib/data to render at all. ApprovalActionPanel and
ProgramCourseBuilder no longer mock it, and ProgramView is driven
entirely by props. The wiring that moved into ProgramViewRoute gained
tests for the department lookup, the error pass-through, and the
combined loading state.

RegistrarDashboard was listed in the architecture review but needs no
change: it is rendered directly by src/app/dashboard/page.tsx, so it is
the route screen, already at the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every record a release attempt reads or writes had its path baked from
process.cwd() at import time, so the thirteen functions that decide
whether a half-finished publish can be recovered could not be exercised
against anything but a real checkout. Twelve of them had no test at all.

releaseRecordPaths(cwd) now resolves the whole set from one directory,
and each record function takes an optional cwd. Callers that pass
nothing behave exactly as before.

The new suite covers what was previously unverified: a pending attempt
can be claimed only once, an upload records its version against the same
attempt and refuses a changed one, a reconciled attempt archives
read-only, a cancellation refuses to discard an attempt that already
uploaded a version, ownership is sealed at 0400 while the current
deployment stays replaceable, and the history appends each version once
and refuses a malformed file.

This is the safety net the wider release-target facade would need. The
facade itself is not attempted here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scanFilesForSecretPatterns kept "Patterns" from a signature that once
took them as a parameter. The patterns now live in findSecrets, so the
name described an argument the function no longer has.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs/agents/domain.md told the engineering skills to read CONTEXT.md and
docs/adr/ and then to proceed silently when neither existed, while
illustrating the layout with two ADR filenames that were never written.
Both now exist and the example names the real one.

CONTEXT.md fixes the vocabulary the other prose files assume rather than
restating them: the curriculum terms of art, the workflow nouns, the
local-first runtime terms, and the AI and release terms that security
review depends on.

ADR-0001 records why AI session readiness is remembered client-side. The
architecture review asked for it to be derived by asking the server; the
authority is an HttpOnly __Host- cookie the browser cannot read, and the
only session route is the Turnstile challenge itself, so asking would
mean a new unauthenticated endpoint reporting cookie validity. That
reasoning lived only in a commit message and would have been
re-litigated by the next review.

Neither file is a release input: RELEASE_INPUT_ROOT_FILES allowlists
root files explicitly and docs/ is not a release input directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking a breadcrumb, an internal link, or a Navigation API intercept
were covered. The paths no click reaches were not: beforeunload,
pagehide, legacy popstate, unmount, and the shell-wide flush that
Settings and the PWA updater call before destroying local data.

Those are the paths that lose a draft when they regress, and they are
the ones a change to where the draft session lives is most likely to
drop. Pinned against current behaviour, including that a clean editor
neither saves nor warns, that a failed save reports false so the shell
refuses to reset, and that nothing saves after unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CourseEditor held the working copy, the autosave debounce, the in-flight
save coalescing, the crash-recovery mirror, the cross-tab conflict
detector, and six ways to save without a click, interleaved with the tab
strip, the comment form, and the submit button. Reading any one of them
meant reading all of them.

useCourseDraftSession now owns that, and the editor asks it for a value
to render and four things it can do: change, flush, navigate, and export.
The component drops from 1065 lines to 614 and no longer names a single
ref.

Two things the split made visible rather than changed:

The conflict check is broader than the exit paths'. It treats an
in-flight save as pending work, because a save that has not returned may
still be about to write; the exit paths ask the narrower question,
because an in-flight save is already doing the work they would start.
That difference was previously two similar expressions 200 lines apart
and is now a comment at the one place it matters.

A failed submission and a failed save shared one state variable. They
are now separate, rendered through one alert, because the submit path
cleared the save message at a point where a successful flush had already
cleared it. The submission failure had no test at all; it has one now.

course-draft-session.test.tsx covers the session directly, which the
extraction is what makes possible: recovery entries written for another
course or unreadable, the saved-to-idle window, save coalescing, the
conflict refusing to save, routing withheld when the save fails, and the
unsaved-draft export. 517 -> 531 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
publishWorker and promoteUploadedVersion could not be exercised without
a Cloudflare account. They were not, as an earlier note claimed, calling
spawn directly — they call runPausedPublisher, which has taken a
spawnProcess parameter all along. What they did not do is accept one to
forward, and they also called the two preconditions, the child
environment builder, and readFile as module-level bindings.

All of that is now one WRANGLER_PUBLISHER value, substituted as a single
argument rather than six threaded parameters, because it is one concept:
everything the publish steps do outside this module.

The tests that become possible are about order, which is what the
sequence exists to get right. Neither command runs before both
preconditions pass. The deploy does not run until the uploaded version
id has been handed back for the pending record, so a publish interrupted
between the two is recoverable rather than lost. A non-zero upload stops
before the deploy; a non-zero deploy still leaves the upload recorded. A
promote deploys the version it was given and never uploads.

This is the prerequisite for collapsing the release target's surface,
not that collapse. 136 -> 148 script tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pending record is the only thing that makes a half-finished publish
recoverable, so who writes it and in what order is the safety property
that matters. Both the publish path and the recovery path built and
mutated it inline: an eighteen-field literal in one, and four places
that assigned versionId, versionTag, and origin and then called update,
which a reader had to match up by eye to see they were the same
operation.

ReleaseAttempt owns it. open builds the record and makes it durable
before the first mutating Wrangler command; recordUpload binds the
attempt to the version Wrangler returned and persists immediately,
because it is called from inside the publish between the upload and the
deploy, and that write is what turns a lost publish into a recoverable
one; finalize promotes and archives. Recovery resumes the same object,
so the reads that follow still see what was written, as they did when
the mutations were inline.

No behaviour changes. The record layer's own guards are unchanged and
still reject a second attempt over an unreconciled one, a rebind to a
different version, and a move to a different origin - now demonstrated
rather than assumed, since ReleaseAttempt takes cwd and can be driven in
a temporary tree.

The attempt lifecycle had no tests. It has ten. 148 -> 158 script tests,
541 -> 553 overall.

Not done, and not on the report's terms: the 36 exports of
cloudflare-release-target.mjs stay. Thirteen of them are pure parsers
and validators with direct tests, and hiding them behind publishRelease
/ recoverAttempt / cancelIfUnchanged would trade that coverage for a
shorter name list on a path that still cannot be executed once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both documents this work started from live at OS temp paths, which do
not survive a reboot. The one candidate still open is C5, and its
problem statement existed only inside a generated HTML report there.

The handoff carries C5 forward with its sites, its proposed shape, and
the two numbers in the report that no longer match the code, plus the
findings and decisions that would otherwise have to be rediscovered.
Everything already recorded in a commit message or ADR is referenced
rather than repeated.

docs/ is not a release input: RELEASE_INPUT_ROOT_FILES allowlists root
files explicitly and docs/ is not a release input directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A call-site census over src/ and e2e/ found fourteen of the interface's
forty-seven methods with no production caller. They were not a uniform
kind of dead weight:

  updateCourse, replaceCourseSLOs, replaceCourseContent,
  replaceCourseRequisites, setCourseCCNJustification

reimplemented, line for line, work saveCourseAggregate already does for
the same fields in one transaction. Keeping both meant two copies of the
TOP/CB03 canonicalization, the requisite cycle check, and the CCN
non-match validation, either of which could drift from the other.

  getRevision, subscribe

forwarded to the invalidation bus the caller can reach directly.
useRepositoryRevision now subscribes to that bus, which is the same
shared instance the repository was handing it back.

  listComments, deleteComment, deleteProgram, getAIConversation,
  deleteAIConversation, listAIArtifacts, deleteAIArtifact

had no caller at all. Comments already arrive on the course aggregate,
so listComments duplicated a read the screens get for free.

No behaviour is dropped that a caller could observe. The tests that
drove the removed course verbs now drive saveCourseAggregate through
named helpers, so post-approval immutability, TOP inference, CB03
agreement, requisite cycles, and CCN adoption stay pinned to the same
assertions — on the one implementation that survives.

Note for anyone reading the numbers: an earlier census missed
getRevision/subscribe because hooks.ts calls them as
`curriculumRepository.subscribe.bind(...)`, with no paren after the
name. Grep for the bare member, not the call.

repository.ts 2291 -> 1900 lines; the interface 47 -> 33 methods.
553 unit tests pass; repository.ts coverage 96.2% lines / 88.67%
branches against its 90/85 floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`database` was a public field on DexieCurriculumRepository, so the test
file reached tables directly in 21 places while the interface it was
meant to be testing sat unused beside it.

The field is now private and one documented accessor,
unsafeDatabaseForTests, hands it back. The name is the point: reaching a
table is not a normal thing to do, and a reader can tell at the call
site that a test chose to go around the interface.

Eleven of those call sites had to keep the seam. They arrange states no
public verb can produce — a schemaVersion 1 record to migrate from, a
newer schemaVersion the repository must refuse, a course row forced into
each of the five statuses so the workflow matrix can try every edge from
it, and bulk fixtures that exceed the conversation and artifact prune
caps. Adding read verbs to serve those would have widened the interface
to narrow the test surface.

The accessor is a method rather than the free function first sketched
for it: a function outside the class cannot read a private field without
a cast, and the method needs none. Production is unaffected either way,
because `curriculumRepository` is exported as CurriculumRepository and
that interface does not declare it.

553 unit tests pass; lint and typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Writes were parsed; reads were not. Every row the repository returned was
trusted because some earlier version of this code had parsed it on the
way in — which is exactly the assumption a local-first demo cannot make.
A browser holds records seeded by an older build, restored from a backup,
or left behind by a migration, and a field that has since changed shape
would surface as undefined somewhere down a screen rather than as an
error at the boundary.

CourseAggregateSchema and ProgramAggregateSchema compose the part schemas
that already existed, and the two private aggregate funnels parse through
validateOnRead. Both getCourse and getProgram go through those funnels,
and so do the aggregates returned from saves, so a write that produced a
record the schema rejects cannot report success either.

getDashboard validates only recentActivity. Its counts are derived in the
method from rows it just read; recentActivity is the one part it hands a
screen unchanged.

List and count paths are deliberately left alone. listCourses reads every
course row on each query and re-runs on every revision bump, so parsing
there would put a Zod pass on the hot path to catch a record that
getCourse will catch when the user opens it.

A failure is reported as invalid-backup with the parse issues attached
and a message naming the recovery — reset the demo data or restore a
backup — because that is what a corrupt local record actually needs.

554 unit tests (one new: a units field stored as a number, caught through
getCourse, through the program join, and a non-UUID actor key caught in
the dashboard's activity). repository.ts coverage 96.22% lines / 88.72%
branches against its 90/85 floor.

The AppShell coverage-run failure noted in the 2026-08-02 handoff
happened once more here and passed on rerun, unchanged. Third occurrence,
still environmental.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CurriculumRepository was one flat list. Reading it told you the table
layout, not what any caller does with it, and the deep verbs — the one
transactional course save, the workflow transition, the backup
round-trip — sat between listNotifications and getReferences with
nothing marking them as the ones that carry weight.

It is now composed from six roles named for the caller that needs them:
CurriculumReads, CourseAuthoring, ProgramAuthoring, NotificationInbox,
AIPersistence, DemoAdministration. The union is unchanged, so
curriculumRepository and every existing call site keep working; what
changes is that a module can now ask for the role it uses.

Two already do. chat-persistence.ts had hand-rolled
`Pick<CurriculumRepository, "listAIConversations" | ...>` before the
roles existed, which is this refactor done ad hoc for one module;
ai/persistence.ts now names getActivePersona plus artifact writing as
what it depends on, instead of importing the whole repository to use two
verbs.

AIPersistence was one role until the chat-persistence doubles rejected
it: those tests build a three-method conversation double, and demanding
saveAIArtifact of them would have meant padding a double with a method
its subject never calls. Splitting into AIConversationPersistence and
AIArtifactPersistence, with AIPersistence extending both, is the seam
the callers actually wanted, and the doubles stay faithful.

Comment verbs sit in CourseAuthoring because this demo only comments on
courses. setActivePersona sits in DemoAdministration because there is no
account here — choosing who you are is a demo control, like reset and
backup, not authentication.

Route screens still import the union singleton. Splitting them across
two or three role imports each would fragment the one entry point they
have for no gain the roles do not already give a reader.

554 unit tests pass; repository.ts coverage 96.22% lines / 88.72%
branches; lint and typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The review that produced C5 asked for the interface to collapse into six
aggregate verbs. A call-site census showed that premise was wrong, and
the work went a different way. Without a record, the next review reads
the same 33-method interface and proposes the same collapse.

ADR-0002 carries the census result, the four changes that were made
instead, and the six alternatives rejected along the way — including the
two that look like obvious improvements until you check what the tests
need to arrange.

It also records two things found by doing the work rather than planning
it: a test double refusing a role is evidence about the role, not an
obstacle to route around; and saveAIArtifact is write-only, which the
deletion exposed rather than caused.

npm run verify exit 0 at this commit: 554 unit tests, 174 worker tests,
build, worker dry-run, and the chromium smoke suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2026-08-02 handoff carried three errors, each re-verified against the
code before being corrected here.

The scope-invariant command is the one that mattered. Both its pathspecs are
cwd-relative, so run from `calricula_pwa_demo/` — the working root that same
document declares — the exclude matches nothing and it lists all 267 demo
files. It reads as a catastrophic boundary violation when nothing is wrong.
From the repository root it is correctly empty. CLAUDE.md carried the same
defect and would have misled every future agent; AGENTS.md already said
"run from the repository root" and was always correct. Both now explain why
the cwd matters rather than just asserting the location.

The `repository.database` assertion count was understated seven-fold: the
handoff said three, citing 79/266/313, because those were the lines its
source report happened to name. There were 21, counted from
f451f02:src/lib/data/repository.test.ts. That understated the C5 estimate.

Loose end #1 was already closed when written — `docs/handoffs/` does appear
in docs/agents/domain.md:27, added by the same commit that added the handoff.

Corrections are marked in place rather than rewritten away, and an errata
section sits above the body so a reader hits it before trusting anything.
The C5 sections are marked superseded and pointed at ADR-0002, since C5 was
not built in the shape that document proposes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
johnnyrobot and others added 29 commits August 4, 2026 14:31
Records the work between the current green tree and a public beta, so the
sequence is not re-derived next session.

Three gaps this names, each verified against the code rather than carried
from a prior handoff:

Live-model qualification covers 2 of 7 AI task routes. ai-evaluate.mjs runs
one plain and one structured fixture per candidate (FIXTURES_PER_CANDIDATE =
2), while worker/index.ts enforces seven per-task validators, three of them
demanding: content-outline requires generated contact hours to sum exactly to
the supplied total, top-code requires the title to match the server-owned
catalog character-for-character, compliance-explanation requires citations
from a server-owned pack. A model can pass both current fixtures and fail
those on every request. The stubbed-provider contract for all seven routes
already exists in tests/worker/ and worker/index.test.ts; what has never been
tested is whether a real free model can satisfy it.

The AI artifact table is write-only by construction, not by oversight.
persistence.ts is the only caller of saveAIArtifact, and 6834d95 removed
listAIArtifacts and deleteAIArtifact from the interface as unreachable, so
nothing can read or delete what a beta would accumulate.

Nothing turns a user report into a reproduction. The privacy design forbids
logging prompts or responses, so triage has only the error code, the status,
the requestId already carried to AIRequestError, and what the user was doing.

The design constraint that shapes the rest: MAX_DAILY_ATTEMPTS = 5 per install
per UTC day makes a seven-route canary through the deployed Worker impossible.
So qualification calls OpenRouter directly with the maintainer key, bypassing
the Worker and its quota, and the deployed canary stays at the two requests
AGENTS.md fixes it at. Each tier answers the question it can afford to.

The plan is proposed, not approved or started. It changes tracked src/lib/**
from 46 to 47, which AGENTS.md and HANDOFF.md publish as a provenance check;
the task that adds the file updates both in the same commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Defines the seven AI task fixtures the Worker exposes, each with a
deterministic rubric. Every check is tagged workerEnforced:

- true  mirrors a validator in worker/index.ts, so failing output would be
        rejected in production as UPSTREAM_INVALID_RESPONSE
- false is a hallucination or quality heuristic deliberately stricter than
        the Worker, which cannot enforce a prompt-level expectation

scoreFixture stays the conjunction of all checks, so pass.rate === 1 remains
the strict deployment bar; workerEnforcedVerdict exposes the provable subset
for the Worker parity test.

Shapes are mirrored from worker/index.ts rather than from the plan sketch:
content-outline topics are {sequence, topic, contactHours, relatedSloNumbers}
with consecutive one-based sequencing, and compliance citations are
{sourceId, supports} with required recommendations and humanReviewRequired.

A sibling .d.mts is required because tsconfig sets allowJs: false and the
parity tests import this module from TypeScript.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ai:evaluate qualified a candidate on two fixtures: plain chat and a two-item
SLO array. A free model could pass both and still fail the other five Worker
output validators on every request, reaching users as UPSTREAM_INVALID_RESPONSE.

The evaluator now loops the fixture registry, so FIXTURES_PER_CANDIDATE rises
2 -> 7 and MAX_GENERATION_REQUESTS 8 -> 28. Requests are paced by
REQUEST_SPACING_MS so a four-candidate run cannot trip a free-tier rate limit
and score a model as failing for a reason unrelated to its output.

Routing policy is unchanged and still byte-identical: allow_fallbacks false,
data_collection deny, zdr true, zero max_price, temperature 0, no retries.

Results now carry pass.byTask and per-task check IDs. runCli takes an options
bag so the no-content-on-stdout guarantee is testable without a live network
call or a real 18-second pacing delay.

ai-eval-samples.mjs holds one accepted and one rejected output per task,
shared with the Worker parity test so the evaluator's idea of passing output
and the Worker's cannot drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rubric decides whether a candidate model is fit to deploy. If it accepted
output worker/index.ts rejects, a model would pass qualification and then fail
every real request as UPSTREAM_INVALID_RESPONSE. Two independent drift risks,
two tests.

Constant drift: the .mjs fixtures duplicate AI_TOP_CODE_CATALOG and
AI_COMPLIANCE_SOURCE_PACK because a plain Node script cannot import the
Zod-bearing module. A jsdom test now deep-equals both. The eval pack gains url
and checksum so that comparison is total rather than a subset.

Verdict drift: a Workers-pool test pushes one accepted and one rejected sample
per task through the real handleRequest and compares verdicts. Only the checks
tagged workerEnforced are compared -- the rubric-only checks are deliberately
stricter than any validator can be, so comparing the whole rubric would fail by
construction. Verified to have teeth: loosening hours-sum-exact makes it fail.

The Worker keeps a third, module-private copy of both catalogs. It is pinned
behaviourally: every eval catalog code and every source ID must survive the
Worker's own comparison, and a code outside the catalog must not.

Tracked src/lib/** rises 46 -> 47; AGENTS.md and HANDOFF.md publish that count
as a provenance check, so they move in the same commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… budget

release-evidence hard-asserted pass.plain and pass.structured, so it would have
rejected every result the new evaluator produces. It now requires a boolean
verdict for all seven task routes and refuses a release unless each one is true
-- a rate of 1 alone can no longer stand in for the per-route verdicts, and the
superseded two-fixture shape is rejected outright.

AGENTS.md now states both budgets and which one may grow. ai:evaluate qualifies
each candidate on all seven routes at 28 requests maximum; the deployed canary
stays at exactly one text and one structured request, because
MAX_DAILY_ATTEMPTS = 5 per install per UTC day makes a seven-route canary
impossible and rotating sessions to evade that is prohibited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The aiArtifacts table had a write path and, since 6834d95 removed
listAIArtifacts and deleteAIArtifact as unreachable, no read or delete path.
MAX_AI_ARTIFACTS_PER_ACTOR silently discarded the oldest rows. A beta would
have accumulated real curriculum text a user could neither see nor remove.
That is not an audit trail.

Removed end to end rather than at the call site alone: both component callers,
the persistence module itself, saveAIArtifact and pruneAIArtifacts on the
repository, and the AIArtifactPersistence and AIArtifactQuery contracts. The
panel's onAccepted prop goes too -- it existed only to feed this write, and
leaving a prop with no caller would recreate the same unreachable interface
this commit is removing.

The Dexie table stays declared. Dropping it is a schema migration and existing
installs may hold rows; it simply stops growing, and the backup round trip
still carries it.

Re-adding a read path later is a feature with a UI and its own accessibility
and e2e coverage, and should be planned as one.

Tracked src/lib/** falls 47 -> 45; AGENTS.md and HANDOFF.md move with it. The
vitest threshold entry for the deleted file is removed -- no surviving file's
floor is weakened, and coverage still exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server logs no prompt, response, or curriculum text by design, and the
quota Durable Object stores only an HMAC-derived install ID, a day, an attempt
count, request IDs, and an expiry. Triage therefore runs on the error code, the
HTTP status, the request ID, and what the user was doing -- and the first and
third only reach a report if the failure state puts them on screen. The alert
region rendered the message and the retry hint but neither of those values.

Both reachable AI surfaces now render the pair: AISuggestionPanel and
AIChatPanel, via one shared ErrorDiagnostics component rather than two copies.
A browser-side rejection reports AI_OUTPUT_REJECTED so a report points at the
right side of the client/Worker boundary, and the request ID line is omitted
when the failure carries none. The raw upstream message is still not surfaced.

Rendered inside the existing role=alert region, so a failure is announced once,
in gold-ink (#7E6018) because this is small text on parchment. axe passes on
all six scanned routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Twenty-seven error codes existed and a requestId already flowed Worker ->
envelope -> AIRequestError, but there was no documented path from a beta user's
report to a reproducible local failure.

The runbook is superpowers:systematic-debugging specialised to this system's
one hard constraint: the server cannot tell you what happened, because it is
designed not to know. Triage therefore runs on four signals only -- code,
status, request ID, and what the user was doing.

Contains the intake block (and what never to ask for), the complete 27-code
hypothesis table, a four-rung reproduction ladder ordered cheapest first, the
model-delisted procedure written out as steps because it is the most likely
real beta incident, and the rule this project keeps re-learning: "it passes on
rerun" is evidence for a timing defect, not against one.

Verified rather than asserted: every referenced path exists, every quoted
command was run and behaves as described, and the code table was diffed against
worker/index.ts -- it covers every Worker code with no extras.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cker

Observed while verifying the public-beta work: test:e2e:full fails about once
per 84-test run and the failing test moves between runs and browsers. It
reproduces at cebba8a in a clean worktree, before any of this work, so it is
pre-existing rather than a regression.

Recorded rather than absorbed, because test:e2e:full is step 11 of 14 in
RELEASE_GATE_STEPS and the gate seal is only written after every step passes --
so this will intermittently block Phases 2 and 4.

Deliberately not called environmental. One observed failure is a plausible
product behaviour: after "Close editor" the URL was the approved source course
rather than the draft just created. Treating "it passes on rerun" as proof of
absence is what let the AppShell defect survive three handoffs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test:e2e:full failed about once per 84-test run with a different test and
browser each time, which made it read as environmental. It was two real
defects, both reproduced at cebba8a before the public-beta work.

Root cause 1 -- worker oversubscription. run-release-e2e.mjs used Playwright's
default worker count, ceil(cores/2) = 6 on a 12-core host. That heuristic
assumes one light browser; this suite drives three projects per group, each
worker running a full browser that spawns several processes. Measured load
average hit 21.1 on 12 cores and the slowest test stretched from 7.8s serial to
24.1s, against a 30s default timeout. With ~6s of headroom, ordinary scheduling
jitter pushed some test past the deadline about half the time.

browserProjectWorkers() now divides cores by three, which returns the slowest
test to about 10s. No timeout was raised and no retry was added: the deadline is
unchanged and the tests do the same work -- the contention blowing through it is
what is gone.

Root cause 2 -- navigating before an async write committed.
resilience-a11y.spec.ts switched persona with a bare selectOption and
immediately called page.goto("/approvals/"). setActivePersona is an async
IndexedDB write, so the docket could render under the previous persona and the
awaited approval card would never appear -- a 30s hang independent of load.
workflow-depth.spec.ts already had the correct helper, which waits for the
AppShell live-region announcement; it moves to e2e/helpers.ts as switchPersona
and both specs now use it.

Verified: 5 of 5 consecutive full runs pass on a settled machine, from 2 of 3
failing before. The worker cap is unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AISessionDataSchema is strict and declared only expiresAt, but the Worker's
session response also carries remainingDailyAttempts. validateAISessionData
runs unconditionally on every session response (src/lib/ai/client.ts), so it
threw AIOutputValidationError every time and establishAISession could never
resolve. No AI feature could start in a real deployment.

Nothing caught it. The Worker suite asserted the response shape but never ran
the browser validator over it, the schema unit test only passed { expiresAt },
and the E2E suite forces AI_ENABLED=false so the session path never executes.
It fails closed, so it was a broken-demo bug rather than a security hole -- but
it would have surfaced as a dead AI surface on the first real beta request.

Adds the field, bounded by the quota protocol's own invariant and optional so
an older deployed Worker still validates, and closes the seam that hid it: a
parity test now pushes the real session response and all seven real task
responses through the browser validators. Verified to have teeth -- removing
the field again fails it.

Found during the D1 security review of b446c76..4751437.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two defence-in-depth gaps found during the D1 security review. Neither was
exploitable in the guarded release path, so neither was filed as a finding, but
both contradict invariants AGENTS.md states plainly.

1. Three spawn sites bypassed the shared scrubber and forwarded raw process.env
   to third-party children -- verify-fresh-checkout.mjs (including npm ci, which
   runs package install scripts), run-release-e2e.mjs (browsers and wrangler
   dev), and local-production.mjs. The gate already hands them a scrubbed
   environment, so only a manual invocation with secrets exported would have
   leaked, but childEnvironment exists so that one edit protects every spawn
   site and these were the three that opted out. There are now no raw
   process.env spawn sites left.

2. release-deploy.mjs sealed the plaintext secrets copy and only opened the
   try/finally that removed it several awaits later, so a throw from the
   lifecycle lease, evidence invalidation, or attempt open left all three
   Worker secrets on disk. Rather than reorder the statements and rely on the
   next editor preserving that order, withSealedReleaseSecrets binds the
   lifetime to a callback, which makes the mistake unrepresentable.

The cleanup-on-throw guarantee is directly tested; the environment scrubbing is
asserted against a payload containing all three Worker secrets plus a
Cloudflare token, and confirms an ordinary PATH still reaches the child.

npm run verify exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The diff scan started on 2026-08-04 over 43 commits. Records the range actually
under scan, marks the row open until a report exists, and clears the stale
claim that the scan never started.

Adds an "Automated pre-scan review" section recording what an automated review
of the same range found, so the scan has a prior and any discrepancy between
the two is visible rather than silent. It closes no finding and does not
substitute for the scan. It names the three defects fixed inside the scanned
range and the two hardening items deliberately left open, which the scan may
reasonably disagree with.

This commit carries documentation only; f0c5854 remains the last commit with
code in the scanned range.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 54-Hour Rule had three implementations. src/lib/compliance/hours.ts is
the one golden-parity.test.ts pins to the parent Python service; the other
two were literals in component files, and the one that won was the one the
parity test could not reach.

course-draft-session.ts computed totalStudentHours as a sum times a literal
18 and wrote it through saveCourseAggregate on every hours edit. It used
Number(x || 0), so an unreadable value from a recovered or imported draft
persisted the string "NaN". It now calls calculateTotalStudentLearningHours,
whose toFiniteNumber yields 0. The field names differ between CourseViewModel
and WeeklyHoursInput, so the call site maps them rather than widening the
compliance interface to suit one caller.

CourseEditorSections.tsx divided by a literal 54 and rendered a ComplianceMark
whenever |total/54 - units| exceeded 0.25. The parent service explicitly warns
against exactly this (compliance_service.py:72-76): 54 is a district
convention for an 18-week term, not the regulatory minimum, and "compliance
must be evaluated against the 48-hour minimum, not against an exact 54-hour
equality". The 0.25 tolerance was borrowed from the 48-hour minimum check,
where the parent applies it one-sided. The banner is now a neutral reference
readout with no verdict; UNIT-005 and the Section VI audit, which do mirror
the parent, are unchanged.

hours.ts is not modified. It was already deep and already correct; only the
call sites needed to stop working around it.

Tests: the NaN case is pinned at the useCourseDraftSession seam; the readout
is pinned to carry no pass/warn verdict; golden-parity now asserts
calculateTotalStudentLearningHours by name, so a change to its delegation
cannot silently unpin the editor's path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ADR-0003 captures the two decisions behind 460aeeb: compliance arithmetic has
exactly one implementation, and the 54-hour figure is a reference that must
never drive a pass/fail verdict.

The second is the load-bearing one. The editor had been rendering a
ComplianceMark against a two-sided 54-hour equality test, which the parent
Python service explicitly warns against — 54 is a district convention for an
18-week term, not the regulatory minimum. On a regulated public tool that
presented a local convention to faculty as a Title 5 result.

Recorded as an ADR rather than enforced by a lint rule or a grep-style test:
both were considered and rejected as brittle, and neither can explain the
reasoning to the person who would otherwise re-add the check. The rejected
alternatives are written down for the same reason ADR-0002 wrote down its
own — so the next architecture review does not re-propose them.

CONTEXT.md's 54-Hour Rule entry gains the distinction it was missing. Its
claim that hours.ts is the only implementation was false when written and is
now true.

Committed separately from the code so that reverting the change does not
silently revert the reasoning that says not to re-add it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f0c5854 claimed every spawned child's environment goes through
childEnvironment(). Two sites never did, and the security diff scan over
b446c76..f0c5854 reported clean without catching either.

run-lighthouse.mjs passed {...process.env, CHROME_PATH, CI} to the Lighthouse
child, which launches headless Chrome — the widest third-party surface in the
release path. The file did not import child-environment.mjs at all.

release-inputs.mjs called execFile('git', ...) with no env key, so Node
inherited the parent environment wholesale. assertTrackedReleaseInputs is the
first thing release-deploy, release-gate, release-evidence and
verify-fresh-checkout each do.

Neither is exploitable: git and Chrome are not the threat model. Both
contradicted a stated invariant, which is the same reasoning f0c5854 applied
to the three sites it did fix.

Both sites were also untestable, which is why the gap survived. Each now has
the seam the other four wrappers already have: lighthouseChildEnvironment is
a named export asserted to deny every CHILD_SECRET_KEYS entry while keeping
PATH, CHROME_PATH and CI; assertTrackedReleaseInputs takes an injectable
execute on its existing options bag, so a test can observe the environment
its git children actually receive.

grep for '...process.env' and 'env: process.env' under scripts/ and worker/
now returns nothing.

Not done here: the nine duplicated stdout/stderr capture blocks with five
different overflow policies, and the consolidation of all fourteen sites
behind one spawn module. That touches release-deploy.mjs, whose core is
unreachable by tests and only runs against real Cloudflare, and is queued as
its own candidate rather than bundled with a security correction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The security diff scan over b446c76..f0c5854 reported clean. That is recorded
in the executive status, the status table, and the scan-state section, which
until now all described it as still running.

Three corrections go with it.

The claim that every spawned child's environment goes through
childEnvironment(), with no env: process.env spawn site remaining, was false
when written and stayed false through f0c5854. It is marked as a dated
correction rather than quietly rewritten, because the previous text is what
the scan was given as context.

The clean result is qualified. The scanned range contained two unscrubbed
spawn sites and the scan did not report them, so a clean diff scan is
evidence about the findings it looked for and not proof that a stated
invariant holds. Invariants get checked against the code; e683986 now checks
these two with tests.

A second diff scan is owed over f0c5854..HEAD and is recorded as an open row
rather than left implicit, with the attention list started. One batch scan at
a stable head, not one per commit.

Deferred: the handoff is still stale in other ways found during the
architecture review — the header date, a git checkpoint sixteen commits
behind, a 44/45 file-count contradiction, unreferenced docs/plans and
docs/runbooks, and verify evidence pinned to c83084f. Those are a single
reconcile pass once the architecture work stops moving HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CourseEditRouteScreen held a 132-line save() behind ten early returns. It
threw RepositoryError five times — the data layer's error class, with a
'validation' code, raised by a component about rules the repository never
enforced. Reaching any of it in a test meant satisfying every guard and
mocking the whole @/lib/data barrel, including a hand-rolled RepositoryError
class so the screen's throws still worked.

planCourseSave is pure: a draft plus its context in, a saveCourseAggregate
command or the reasons it cannot be built out. No repository, no React, no
guards. The course id stays out of it — a plan says what to save, not where.
The screen keeps the guards and the wiring, and converts a failed plan into
the plain Error the draft session already renders; it never inspected the
class, only error.message.

Validation stays fail-fast so this diff is about where the logic lives, not
about changing what a user sees. issues is an array so collecting every
reason later needs no interface change.

Four tests move to the module, where they assert a returned object instead of
driving a screen: the temporary-client-ID projection, both CCN non-match
rules, and the CB03/TOP reconciliation. The atomic-commit test stays on the
screen as the wiring guard. The CCN tests now run the real planCCNAdoption
against a standard fixture rather than a mocked plan, so they cover the
integration the screen test could only simulate.

Extracting found a live defect on the way: childId tests for the 'tmp-'
prefix, and a first pass at the module wrote 'temp-'. That would have sent a
draft-only child to the repository as a persisted row. The migrated test
caught it; it is pinned now.

Not done, and deliberately: makeCCNMatches and aggregateAudit have the same
"logic in a screen" shape but are read-path derivations, and the two-pass
confidence fallback in makeCCNMatches deserves its own argument.

Considered and rejected: moving the save into the repository. It would make
the repository speak CourseViewModel, which ADR-0002 deliberately kept out of
its vocabulary, and would trade a pure function for one that needs a database
to test.

CourseRouteScreens.tsx 680 -> 546 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
worker/index.ts held the TOP code catalog, the compliance source pack,
their membership predicates and the two system prompts built from them.
Reaching any of it meant a Turnstile exchange, an HMAC round trip and a
quota reservation, so the data the AI is allowed to cite was only ever
asserted through HTTP.

worker/catalog.ts now owns them, following the seam free-routing.ts and
quota-protocol.ts already established: a pure module with a sibling test
that never touches the network. The one caller that reached into the
code->entry Map now asks topCodeTitle() instead, so the Map stays private
and the module exposes the question rather than the structure.

Behaviour is unchanged. The new tests pin what the HTTP path could not
assert cheaply: the four source ids and their checksums, the twenty TOP
codes, that inherited object keys are not catalogue members, and that
both prompts still carry their prompt-injection guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
worker/index.ts was 2520 lines exporting one function, so the seven task
definitions could only be reached through it. Asserting that the chat
validator trims its output cost a Turnstile exchange, an HMAC round trip
and a quota reservation, which is why most validators had no direct test
at all and the eval-parity suite minted a fresh session per case.

worker/tasks.ts now owns the schemas, the TASKS registry, the route
table and the output-validation primitives. OutputValidationError moves
with them rather than joining ApiError: it deliberately carries no status
or code, because handleRequest is the single place that turns it into a
502 UPSTREAM_INVALID_RESPONSE. index.ts drops to 1706 lines and its diff
is a pure deletion plus one import — every one of the 682 moved lines is
byte-identical, the only edits being the six declarations that gained an
export keyword.

Tests follow the seam. worker/tasks.test.ts asserts all seven validators
directly, including the cases that were never worth their HTTP cost:
non-consecutive topic sequences, a catalogued TOP code wearing a title
the catalog does not give it, a compliance citation replaced by the
server's own source metadata. worker.test.ts keeps exactly one HTTP case,
now also asserting the 502 names neither the failing field nor the
rejected value. ai-eval-parity compares the rubric synchronously and
keeps one HTTP case on content-outline — the only task whose validator
reads the request input, so it is the case that proves handleRequest
still threads input through to the validator the rubric is pinned to.

Behaviour is unchanged; the worker suite goes 216 -> 225.

Follow-up: the catalog-parity block in ai-eval-parity still runs nine
HTTP round trips, justified by a comment claiming the Worker's catalog is
module-private. That stopped being true in 500095d; the comment is
corrected here, but the block itself could now compare data directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TOP catalog and the compliance source pack were written out three
times, in three shapes: an ordered array in the Worker, a code-to-title
map in the browser, a frozen map in the eval fixtures. The compliance
pack additionally used two key spellings — the Worker said title/section
and renamed them to sourceTitle/sourceSection on the way out.

shared/ai-catalog.ts now holds one copy. worker/catalog.ts keeps only
what is Worker-only (the membership predicates and the two system
prompts); src/lib/ai/schemas.ts re-exports under the names the UI already
imports, so no component or test changed. sourceTitle/sourceSection won
because that is what crosses the wire and what CourseAIControls renders,
which turns the Worker's five-field rename into a spread.

The premise this was planned against turned out to be wrong and is worth
recording. Both the handoff and the comment at eval-catalog-parity.test.ts
said the Worker's copy could not be imported from the default vitest
project because it only runs under the Workers pool. It can: exclude
['worker/**'] stops worker tests from running there, not from being
imported. A probe test in the jsdom project imported worker/catalog.ts
and worker/tasks.ts and ran a validator. Nothing ever required a
plain-data lowest common denominator on the TypeScript side, so the third
copy could simply be deleted rather than parity-tested.

What remains is one duplicate, for a reason that has not gone away:
ai:evaluate runs scripts/ai-eval-fixtures.mjs under plain Node, which
cannot import TypeScript. eval-catalog-parity.test.ts pins it to the
shared module directly now, and its claim about a Worker third copy is
corrected.

shared/ is added to both the vitest include and the coverage include, so
moving code out of src/ does not quietly shrink what the coverage floor
measures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every AI output bound was written out three times — once in the Worker's
JSON schema, once in the Worker's validator, once in the browser's Zod
schema — plus two or three more times in the evaluation rubric. Around
forty-five literals for nineteen bounds.

This is not a tidiness problem. The browser re-validates the Worker's
response, so a bound that differs by one between the two does not fail
loudly: it fails as AI_OUTPUT_REJECTED on a response the Worker
considered perfectly valid. That exact failure has happened before, which
is why the parity test exists at all. shared/ai-limits.ts makes the
agreement structural rather than coincidental. All nineteen agreed
before this change — it is preserving a property, not fixing a break.

Only bounds both sides express moved. The Worker's 7-character TOP code,
its 80-character sourceId, the browser's bounds on the citation metadata
the Worker itself injects, and confidence 0..1 each have one enforcer, so
routing them through a shared module would add a hop without adding an
agreement.

scripts/ai-eval-fixtures.mjs keeps its copy, because ai:evaluate runs it
under plain Node and Node cannot import TypeScript. It now declares the
bounds once as EVAL_OUTPUT_LIMITS instead of inlining them, and
eval-catalog-parity.test.ts compares the two objects by value. That
closes the drift that mattered most: the rubric qualifying a model on
output the Worker rejects, so the model passes the gate and then fails
every real request.

Rewiring the rubric is a change to the deployment gate, so it was checked
harder than by running tests. Every fixture's schema, check id and
pass/fail verdict for the good and bad sample of all seven tasks was
captured before the edit and diffed after: identical. The new parity
assertion passes by construction, so it was mutated (1600 -> 1599) to
confirm it fails on drift.

EVAL_OUTPUT_LIMITS is typed loosely in ai-eval-fixtures.d.mts on purpose.
Mirroring the real shape in a hand-written declaration would be a fourth
copy of the contract, free to drift in silence; the value comparison is
the check that carries the weight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repository could only be asked for a page, so five callers each
invented an answer to "give me all of them". One hand-rolled a fan-out
over pageCount-1 parallel reads and rebuilt a fake single-page result;
four passed a page size they hoped was big enough — 250, 250, 100, 50.

The guesses do not work. page() clamps pageSize to 100, so the registrar
dashboard and the approval queue have been asking for 250 and silently
receiving 100, with no error and nothing to indicate rows were dropped.
The dashboard made it visible without making it obvious: "Outlines in
progress — X of N" took N from the uncapped total while X counted only
the hundred rows it got back. The seeded fixture has 22 courses so it
does not show today, but the demo lets a user create courses, so it is
reachable. A test now pins that truncation in place as the reason this
verb exists.

The fan-out was worse than it looked. listCourses already reads the whole
table into memory, filters and sorts it, and only then slices — so
fetching pageCount pages ran a full table scan per page to reassemble
data the first call had already computed and thrown away. listAllCourses
is that method without the final page(), and listCourses is now a slice
of it.

Also removed useRepositoryReady. Nothing outside its own test called it,
and the initialization path that ships is DemoProvider's, which adds
retry and an error state the hook never had — keeping a second, thinner
one only invited someone to use it.

Two claims about this area that did not survive checking, recorded so
nobody re-derives them. useRepositoryRevision is not caller-less: it is
used at hooks.ts:49 inside useRepositoryQuery. And DemoProvider does not
duplicate useRepositoryReady; it does strictly more.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`export * from "./session"` put all nine of that module's exports into
@/lib/ai, while session-readiness — the module ADR-0001 decided callers
should ask — was not in the barrel at all. So the three AI surfaces each
issued two imports, one of them a deep path, to reach the thing the ADR
points them at, and the raw disclosure and session-marker accessors sat
in the package surface next to it.

Only two of the nine had a production caller outside the module:
acknowledgeAIDisclosure and establishAISession, both from AIConsentGate.
Five exist so session-readiness can reach them across a file boundary.
getOrCreateInstallationId had no caller anywhere, and markAISessionReady
had only test callers.

The barrel now exports those two plus session-readiness: nine down to
five, with the readiness module reachable by name for the first time.
Each AI surface is one import, and AIConsentGate's test is one mock
instead of two.

The rule this settles, now written in index.ts: module-level exports are
the file's surface, the barrel is the package's. The five accessors stay
exported because session-readiness has to reach them; they are simply not
@/lib/ai's business. A test that wants the raw marker imports
"../../lib/ai/session", and that path is the marking.

session.test.ts re-declared the three storage keys as literals and
asserted on raw window storage — the same bypass as reaching around the
repository, but with nothing to grep for. It now reads
unsafeSessionStorageKeysForTests, named after ADR-0002's
unsafeDatabaseForTests(), where the name is the decision.

Not done, and deliberately: merging session-readiness into session, which
would make those five private. ADR-0001 is Accepted and names the module
as its implementation, and the merge would force AIConsentGate's test to
mock one coarser module supplying both the readiness answers and the two
verbs the component really calls. The ADR's decision — readiness derived
client-side, no server oracle — is untouched here, and its references
still resolve.

getOrCreateInstallationId stays exported despite having no caller:
session.test.ts exercises it directly and its storage-fallback behaviour
feeds the Worker's HMAC install identity and the daily quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Five of the six repository roles have no importer outside contracts.ts.
That reads like a failed decision, so the amendment says plainly that it
is not one: the benefit ADR-0002 claimed is that a reader learns the role
they need instead of a flat list of names, and that is delivered by the
interface being segmented, not by anyone importing the segments. The
roles stay.

Alternative 6 turns out to be narrower than it looked. It rejected
splitting a screen across two or three role imports, fragmenting the call
site — not a caller naming one role. ContextualAssistant was picking two
verbs out of the whole union; it now picks them out of CurriculumReads,
where both live. Still one import, so the objection does not reach it,
and a second role now has a caller.

Three corrections. The "33 methods" figure was 32 when last checked and
is 34 now — 2e7809f added listAllCourses and listAllPrograms — so it is
marked as that day's census rather than a live number.
AIArtifactPersistence never shipped; 0c3972c settled the role as
AIConversationPersistence, with AIPersistence surviving as an unused
alias. And the entry calling saveAIArtifact write-only and asking for an
explicit decision has had one, in the direction it pointed: no artifact
read, write or delete verb exists, repository.test.ts asserts all three
are absent, and the Dexie table stays declared because dropping it is a
schema migration.

Amendment rather than an edit of the original text, so what was decided
in August and what was corrected later stay separable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RELEASE_INPUT_DIRECTORIES did not list `shared`, so the two runtime
modules added by f1e0535 were outside the canonical release enumeration.
That is not only a fingerprint gap. verify-fresh-checkout copies exactly
these files into a canonical checkout and rebuilds there, so the rebuild
would have hit unresolved imports from both the Worker and the browser
bundles: release:verify:fresh-checkout has been broken since f1e0535.

Nothing shipped wrong — no Worker has been bootstrapped and the gate has
never run — but eight green `npm run verify` runs passed straight over
it, which is the hole HANDOFF.md already describes: verify does not
exercise the release scripts, so their unit tests are the only guard.

So the guard is the point of this commit, not the one-line list change.
release-inputs.test.mjs now walks the demo root and fails if a directory
holding source is not enumerated, which is what would have caught this
the moment shared/ was created. Confirmed by removing 'shared' again and
watching it fail.

`tests/` is recorded there as a deliberate exclusion rather than being
added: Worker unit tests are not a build input. It does mean a change
under tests/ leaves the source fingerprint unchanged, which is worth an
explicit decision rather than a silent widening of what the fingerprint
covers.

The assertion is a subset check, not equality — `public/` is a release
input that ships static assets and no source, so it never appears in the
walked set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reconcile was deliberately deferred while the architecture work kept
moving HEAD. HEAD has stopped, so this brings the document back to the
code.

Recorded for the first time: an automated pre-scan review over
f0c5854..HEAD, the range whose Codex Security diff scan is still owed. It
found nothing, and it lists the checks it ran rather than reporting a
bare "clean" — the previous clean scan covered a range containing two
unscrubbed spawn sites, so scope is the part worth writing down. It is a
prior for the scan and closes no finding. One sub-threshold observation
is noted as belonging to the owed standard scan instead: worker/index.ts
reflects a client-supplied field name into a 400 JSON response, which
predates the baseline and is not in this diff.

Corrected against the code: the reconcile date; the current head and its
29-commit range; the 44-vs-45 runtime-file contradiction, where the
prose and the status row disagreed and 45 was right; and every figure in
"Verification already performed", which was pinned to c83084f and is now
d650b92 — 617 tests in 79 files, 222 Worker tests, coverage
85.31/74.70/82.07/87.13, Worker bundle 71.33 KiB.

docs/plans/ and docs/runbooks/ existed unreferenced by any top-level
document. They are now in the file map, along with docs/adr/ and
docs/handoffs/, each with a line saying what it is for and how far to
trust it — the plan predates the work it describes, so its line
references are as-of its own date.

The eight-candidate review now has a handoff of its own, following the
precedent f451f02 set for the nine-candidate one. It records what each
candidate found, the three defects that were not shape problems, and the
five prior claims that did not survive being read against the code.

AGENTS.md gains the rule the shared/ break exposed: runtime source is no
longer confined to src/lib/**, and any directory that ships source has to
be a release input or fresh-checkout rebuilds without it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A review pass read every checkable claim in the 2026-08-07 handoff back
against the tree. The substance held — the range, the candidate table,
the truncation finding, the release-input break and the worker/index.ts
line all verified exactly — but five entries did not, and four of them
were in the sections whose whole purpose is to stop a later reader
re-deriving something. Those are the ones worth fixing.

The 60-unit program limit does not exist in this demo. It was offered as
the verdict sweep's next target, and there is nothing there to look at:
compliance/rules.ts mentions programs nowhere, totalUnits is a
UnitValueSchema decimal string with no ceiling of any kind, and the
string appears nowhere under src/, worker/, shared/ or scripts/. It is a
rule of the parent Next.js + FastAPI application that crossed the stack
boundary this demo's CLAUDE.md draws. The claim originates in ADR-0003,
so that is amended too, rather than edited — following ed1de9a, so what
was decided and what was corrected stay separable. The sweep still has a
target; it is restated without the bad example.

There is no spawnChild helper to consolidate, which is the actual
finding: a reader greps and gets nothing. Replaced with the survey.
scripts/ has 13 spawn sites, each with its own wrapper — 12 call spawn
directly and release-deploy.mjs:470 goes through an injectable
spawnProcess, which is why a `spawn(` grep finds only 12 and reconciles
this with HANDOFF.md's count. Seven accumulate child output under five
overflow policies, now named with file and line rather than tallied. The
original counts were off on two of three: seven capture blocks, not
nine, and "two unbounded" does not survive any consistent reading of the
code, so the numbers are replaced by anchors.

The owed Codex Security scan no longer carries a range here. It said
f0c5854..d650b92; HANDOFF.md says f0c5854..HEAD and explicitly resolves
at scan time rather than pinning a SHA. HANDOFF.md is authoritative by
this document's own closing paragraph, so the entry defers to it and
there is no second copy to drift.

The hooks census was right and unverifiable at the same time. "Five are"
is correct as of 9b4b2ab, where hooks.ts exported exactly 12 and exactly
five delegated to one repository method and nothing else — but the
document named neither the five nor the commit, and the tree has since
moved to 13 hooks of which four are pass-throughs. Both figures are now
pinned to that commit with the drift recorded, the same treatment
ed1de9a gave ADR-0002's method count.

hooks.ts:49 is 48. Inherited from 2e7809f's commit message, so the error
had already propagated once.

Left alone deliberately: HANDOFF.md:66 reads "14 demo commits,
9b4b2ab..d650b92", but that range is 13 and 14 is the count for
f0c5854..d650b92 — the label and the range disagree by one. It is a real
error, but HANDOFF.md is release-authoritative and its commit counts are
not something to adjust as a drive-by.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The keys cannot be pasted in one sitting, which is the thing a next
session most needs told. Three of them do not exist until earlier steps
produce them: the Turnstile site and secret keys bind to a hostname that
deploy:bootstrap has not yet created, APP_ORIGIN is that same hostname,
and OPENROUTER_FREE_MODELS comes out of ai:evaluate passing two models
under human review. Only the Cloudflare account ID and token, the
OpenRouter key, and the generated HMAC secret can be prepared up front.
The nine stages are that dependency order, not a suggested sequence.

Four destinations, recorded because they are not interchangeable and
three of the four are outside the repository. NEXT_PUBLIC_TURNSTILE_SITE_KEY
is the only key that belongs in the tree, in gitignored .env.local, and
the gate proves that exact value rather than the placeholder was compiled
into the sealed export. The three Worker secrets go in an external 0600
file passed by path, never through `wrangler secret put`, because each
such invocation is its own version change and trips the ownership lock.
Cloudflare and canary credentials stay in the shell for one command.
wrangler.jsonc holds configuration that ships fail-closed and is not a
key at all.

Two constraints written down because neither is visible from the command
list. The recorded discovery and evaluation evidence expires in 24 hours
and is bound to the source fingerprint and the OpenRouter key
fingerprint, so stages E through H are one working window and stage D
should not start outside one. And gate step 10, release:fresh-checkout,
has never run against the current source shape: HANDOFF.md records it
passing at 274d428, which predates shared/ existing, and f1e0535 then
broke it for four commits. d650b92 fixed the enumeration and added the
guard, but the repair is proven by its unit test and not by a rebuild, so
the gate will be its first real exercise. It is also the only blocking
item needing no credentials, which makes it the cheapest thing to
de-risk first.

The decision to drop the Codex Security scans is recorded here rather
than applied to HANDOFF.md's status rows, and the handoff says so
explicitly in both places. Those rows still read as blocking, and a fresh
session that trusts them — correctly, since AGENTS.md and HANDOFF.md
outrank everything else for release work — will stop on them. Restating
them is named as the first task, ahead of any key work, so the
contradiction is visible rather than silently resolved by whoever reads
it next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 283 files, which is 183 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 59e8cdd6-5da2-4884-88cb-7093fa53915b

📥 Commits

Reviewing files that changed from the base of the PR and between efdc0b4 and 08083bb.

⛔ Files ignored due to path filters (5)
  • calricula_pwa_demo/package-lock.json is excluded by !**/package-lock.json
  • calricula_pwa_demo/public/favicon.svg is excluded by !**/*.svg
  • calricula_pwa_demo/public/icons/icon-192.png is excluded by !**/*.png
  • calricula_pwa_demo/public/icons/icon-512.png is excluded by !**/*.png
  • calricula_pwa_demo/public/icons/icon-maskable-512.png is excluded by !**/*.png
📒 Files selected for processing (283)
  • calricula_pwa_demo/.assetsignore
  • calricula_pwa_demo/.dev.vars.example
  • calricula_pwa_demo/.env.example
  • calricula_pwa_demo/.gitignore
  • calricula_pwa_demo/AGENTS.md
  • calricula_pwa_demo/CLAUDE.md
  • calricula_pwa_demo/CONTEXT.md
  • calricula_pwa_demo/HANDOFF.md
  • calricula_pwa_demo/README.md
  • calricula_pwa_demo/docs/adr/0001-client-side-ai-session-readiness.md
  • calricula_pwa_demo/docs/adr/0002-repository-interface-roles-and-test-seam.md
  • calricula_pwa_demo/docs/adr/0003-conventional-hours-are-a-reference-not-a-verdict.md
  • calricula_pwa_demo/docs/agents/domain.md
  • calricula_pwa_demo/docs/agents/issue-tracker.md
  • calricula_pwa_demo/docs/agents/triage-labels.md
  • calricula_pwa_demo/docs/handoffs/2026-08-02-architecture-deepening.md
  • calricula_pwa_demo/docs/handoffs/2026-08-07-architecture-candidates.md
  • calricula_pwa_demo/docs/handoffs/2026-08-07-release-key-provisioning.md
  • calricula_pwa_demo/docs/plans/2026-08-04-public-beta-readiness.md
  • calricula_pwa_demo/docs/runbooks/ai-triage.md
  • calricula_pwa_demo/e2e/accessibility.spec.ts
  • calricula_pwa_demo/e2e/fixtures.ts
  • calricula_pwa_demo/e2e/helpers.ts
  • calricula_pwa_demo/e2e/local-resilience.spec.ts
  • calricula_pwa_demo/e2e/offline-ai.spec.ts
  • calricula_pwa_demo/e2e/programs-backup.spec.ts
  • calricula_pwa_demo/e2e/resilience-a11y.spec.ts
  • calricula_pwa_demo/e2e/smoke.spec.ts
  • calricula_pwa_demo/e2e/workflow-depth.spec.ts
  • calricula_pwa_demo/eslint.config.mjs
  • calricula_pwa_demo/lighthouse.thresholds.json
  • calricula_pwa_demo/next.config.ts
  • calricula_pwa_demo/package.json
  • calricula_pwa_demo/playwright.config.ts
  • calricula_pwa_demo/postcss.config.mjs
  • calricula_pwa_demo/public/_headers
  • calricula_pwa_demo/public/connectivity.txt
  • calricula_pwa_demo/public/offline.html
  • calricula_pwa_demo/public/robots.txt
  • calricula_pwa_demo/scripts/ai-canary-credential.mjs
  • calricula_pwa_demo/scripts/ai-canary-credential.test.mjs
  • calricula_pwa_demo/scripts/ai-canary.mjs
  • calricula_pwa_demo/scripts/ai-canary.test.mjs
  • calricula_pwa_demo/scripts/ai-eval-fixtures.d.mts
  • calricula_pwa_demo/scripts/ai-eval-fixtures.mjs
  • calricula_pwa_demo/scripts/ai-eval-fixtures.test.mjs
  • calricula_pwa_demo/scripts/ai-eval-samples.d.mts
  • calricula_pwa_demo/scripts/ai-eval-samples.mjs
  • calricula_pwa_demo/scripts/ai-evaluate.mjs
  • calricula_pwa_demo/scripts/ai-evaluate.test.mjs
  • calricula_pwa_demo/scripts/build-pwa.mjs
  • calricula_pwa_demo/scripts/child-environment.mjs
  • calricula_pwa_demo/scripts/child-environment.test.mjs
  • calricula_pwa_demo/scripts/cloudflare-release-records.test.mjs
  • calricula_pwa_demo/scripts/cloudflare-release-target.mjs
  • calricula_pwa_demo/scripts/cloudflare-release-target.test.mjs
  • calricula_pwa_demo/scripts/discover-openrouter-models.mjs
  • calricula_pwa_demo/scripts/discover-openrouter-models.test.mjs
  • calricula_pwa_demo/scripts/generate-icons.mjs
  • calricula_pwa_demo/scripts/local-production.mjs
  • calricula_pwa_demo/scripts/prepare-release-build.mjs
  • calricula_pwa_demo/scripts/read-json-with-limit.mjs
  • calricula_pwa_demo/scripts/read-json-with-limit.test.mjs
  • calricula_pwa_demo/scripts/release-attempt.test.mjs
  • calricula_pwa_demo/scripts/release-automation.test.mjs
  • calricula_pwa_demo/scripts/release-deploy.mjs
  • calricula_pwa_demo/scripts/release-evidence.mjs
  • calricula_pwa_demo/scripts/release-evidence.test.mjs
  • calricula_pwa_demo/scripts/release-gate.mjs
  • calricula_pwa_demo/scripts/release-inputs.mjs
  • calricula_pwa_demo/scripts/release-inputs.test.mjs
  • calricula_pwa_demo/scripts/release-publish-sequence.test.mjs
  • calricula_pwa_demo/scripts/release-state.mjs
  • calricula_pwa_demo/scripts/release-state.test.mjs
  • calricula_pwa_demo/scripts/run-lighthouse.mjs
  • calricula_pwa_demo/scripts/run-lighthouse.test.mjs
  • calricula_pwa_demo/scripts/run-release-e2e.mjs
  • calricula_pwa_demo/scripts/run-release-e2e.test.mjs
  • calricula_pwa_demo/scripts/secret-scan.mjs
  • calricula_pwa_demo/scripts/secret-scan.test.mjs
  • calricula_pwa_demo/scripts/serve-static-export.d.mts
  • calricula_pwa_demo/scripts/serve-static-export.mjs
  • calricula_pwa_demo/scripts/serve-static-export.test.mjs
  • calricula_pwa_demo/scripts/static-asset-validation.mjs
  • calricula_pwa_demo/scripts/static-asset-validation.test.mjs
  • calricula_pwa_demo/scripts/validate-build.mjs
  • calricula_pwa_demo/scripts/validate-worker-bundle.mjs
  • calricula_pwa_demo/scripts/verify-fresh-checkout.mjs
  • calricula_pwa_demo/scripts/verify-production.mjs
  • calricula_pwa_demo/scripts/verify-production.test.mjs
  • calricula_pwa_demo/scripts/vitest.setup.ts
  • calricula_pwa_demo/scripts/wrangler-config.mjs
  • calricula_pwa_demo/scripts/wrangler-config.test.mjs
  • calricula_pwa_demo/shared/ai-catalog.test.ts
  • calricula_pwa_demo/shared/ai-catalog.ts
  • calricula_pwa_demo/shared/ai-limits.test.ts
  • calricula_pwa_demo/shared/ai-limits.ts
  • calricula_pwa_demo/src/app/accessibility/layout.tsx
  • calricula_pwa_demo/src/app/accessibility/page.test.tsx
  • calricula_pwa_demo/src/app/accessibility/page.tsx
  • calricula_pwa_demo/src/app/approvals/layout.tsx
  • calricula_pwa_demo/src/app/approvals/page.tsx
  • calricula_pwa_demo/src/app/courses/compare/page.tsx
  • calricula_pwa_demo/src/app/courses/edit/page.tsx
  • calricula_pwa_demo/src/app/courses/layout.tsx
  • calricula_pwa_demo/src/app/courses/new/page.tsx
  • calricula_pwa_demo/src/app/courses/page.tsx
  • calricula_pwa_demo/src/app/courses/view/page.tsx
  • calricula_pwa_demo/src/app/dashboard/layout.tsx
  • calricula_pwa_demo/src/app/dashboard/page.tsx
  • calricula_pwa_demo/src/app/layout.tsx
  • calricula_pwa_demo/src/app/manifest.ts
  • calricula_pwa_demo/src/app/offline/layout.tsx
  • calricula_pwa_demo/src/app/offline/page.test.tsx
  • calricula_pwa_demo/src/app/offline/page.tsx
  • calricula_pwa_demo/src/app/page.test.tsx
  • calricula_pwa_demo/src/app/page.tsx
  • calricula_pwa_demo/src/app/programs/edit/page.tsx
  • calricula_pwa_demo/src/app/programs/layout.tsx
  • calricula_pwa_demo/src/app/programs/new/page.tsx
  • calricula_pwa_demo/src/app/programs/page.tsx
  • calricula_pwa_demo/src/app/programs/view/page.tsx
  • calricula_pwa_demo/src/app/settings/layout.tsx
  • calricula_pwa_demo/src/app/settings/page.tsx
  • calricula_pwa_demo/src/components/ai/AIChatPanel.test.tsx
  • calricula_pwa_demo/src/components/ai/AIChatPanel.tsx
  • calricula_pwa_demo/src/components/ai/AIConsentGate.test.tsx
  • calricula_pwa_demo/src/components/ai/AIConsentGate.tsx
  • calricula_pwa_demo/src/components/ai/AISuggestionPanel.test.tsx
  • calricula_pwa_demo/src/components/ai/AISuggestionPanel.tsx
  • calricula_pwa_demo/src/components/ai/ContextualAssistant.test.tsx
  • calricula_pwa_demo/src/components/ai/ContextualAssistant.tsx
  • calricula_pwa_demo/src/components/ai/CourseAIControls.test.tsx
  • calricula_pwa_demo/src/components/ai/CourseAIControls.tsx
  • calricula_pwa_demo/src/components/ai/ErrorDiagnostics.tsx
  • calricula_pwa_demo/src/components/ai/ProgramAIControls.test.tsx
  • calricula_pwa_demo/src/components/ai/ProgramAIControls.tsx
  • calricula_pwa_demo/src/components/ai/TurnstileWidget.test.tsx
  • calricula_pwa_demo/src/components/ai/TurnstileWidget.tsx
  • calricula_pwa_demo/src/components/ai/index.ts
  • calricula_pwa_demo/src/components/ai/useOnlineStatus.ts
  • calricula_pwa_demo/src/components/approvals/ApprovalActionPanel.test.tsx
  • calricula_pwa_demo/src/components/approvals/ApprovalActionPanel.tsx
  • calricula_pwa_demo/src/components/approvals/ApprovalQueue.test.tsx
  • calricula_pwa_demo/src/components/approvals/ApprovalQueue.tsx
  • calricula_pwa_demo/src/components/approvals/index.ts
  • calricula_pwa_demo/src/components/approvals/workflow.test.ts
  • calricula_pwa_demo/src/components/approvals/workflow.ts
  • calricula_pwa_demo/src/components/courses/ConfirmDialog.test.tsx
  • calricula_pwa_demo/src/components/courses/ConfirmDialog.tsx
  • calricula_pwa_demo/src/components/courses/CourseCatalogHeader.tsx
  • calricula_pwa_demo/src/components/courses/CourseCompareView.test.tsx
  • calricula_pwa_demo/src/components/courses/CourseCompareView.tsx
  • calricula_pwa_demo/src/components/courses/CourseCreateForm.test.tsx
  • calricula_pwa_demo/src/components/courses/CourseCreateForm.tsx
  • calricula_pwa_demo/src/components/courses/CourseDetailView.test.tsx
  • calricula_pwa_demo/src/components/courses/CourseDetailView.tsx
  • calricula_pwa_demo/src/components/courses/CourseEditor.test.tsx
  • calricula_pwa_demo/src/components/courses/CourseEditor.tsx
  • calricula_pwa_demo/src/components/courses/CourseEditorSections.test.tsx
  • calricula_pwa_demo/src/components/courses/CourseEditorSections.tsx
  • calricula_pwa_demo/src/components/courses/CoursePrimitives.tsx
  • calricula_pwa_demo/src/components/courses/CourseRouteScreens.test.tsx
  • calricula_pwa_demo/src/components/courses/CourseRouteScreens.tsx
  • calricula_pwa_demo/src/components/courses/CoursesList.test.tsx
  • calricula_pwa_demo/src/components/courses/CoursesList.tsx
  • calricula_pwa_demo/src/components/courses/DeferredCoursesRouteScreen.tsx
  • calricula_pwa_demo/src/components/courses/adapters.test.ts
  • calricula_pwa_demo/src/components/courses/adapters.ts
  • calricula_pwa_demo/src/components/courses/course-draft-session.test.tsx
  • calricula_pwa_demo/src/components/courses/course-draft-session.ts
  • calricula_pwa_demo/src/components/courses/course-save.test.ts
  • calricula_pwa_demo/src/components/courses/course-save.ts
  • calricula_pwa_demo/src/components/courses/index.ts
  • calricula_pwa_demo/src/components/courses/types.ts
  • calricula_pwa_demo/src/components/dashboard/DeferredRegistrarDashboard.tsx
  • calricula_pwa_demo/src/components/dashboard/MetricPanel.tsx
  • calricula_pwa_demo/src/components/dashboard/RegistrarDashboard.test.tsx
  • calricula_pwa_demo/src/components/dashboard/RegistrarDashboard.tsx
  • calricula_pwa_demo/src/components/dashboard/index.ts
  • calricula_pwa_demo/src/components/programs/ProgramCourseBuilder.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramCourseBuilder.tsx
  • calricula_pwa_demo/src/components/programs/ProgramCreate.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramCreate.tsx
  • calricula_pwa_demo/src/components/programs/ProgramEditor.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramEditor.tsx
  • calricula_pwa_demo/src/components/programs/ProgramEditorRoute.tsx
  • calricula_pwa_demo/src/components/programs/ProgramForm.render.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramForm.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramForm.tsx
  • calricula_pwa_demo/src/components/programs/ProgramList.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramList.tsx
  • calricula_pwa_demo/src/components/programs/ProgramRoutes.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramView.test.tsx
  • calricula_pwa_demo/src/components/programs/ProgramView.tsx
  • calricula_pwa_demo/src/components/programs/ProgramViewRoute.tsx
  • calricula_pwa_demo/src/components/programs/index.ts
  • calricula_pwa_demo/src/components/pwa/PwaUpdater.test.tsx
  • calricula_pwa_demo/src/components/pwa/PwaUpdater.tsx
  • calricula_pwa_demo/src/components/pwa/index.ts
  • calricula_pwa_demo/src/components/settings/SettingsView.test.tsx
  • calricula_pwa_demo/src/components/settings/SettingsView.tsx
  • calricula_pwa_demo/src/components/settings/index.ts
  • calricula_pwa_demo/src/components/shell/AppShell.test.tsx
  • calricula_pwa_demo/src/components/shell/AppShell.tsx
  • calricula_pwa_demo/src/components/shell/BrandMark.tsx
  • calricula_pwa_demo/src/components/shell/DeferredWorkspace.tsx
  • calricula_pwa_demo/src/components/shell/DemoProvider.test.tsx
  • calricula_pwa_demo/src/components/shell/DemoProvider.tsx
  • calricula_pwa_demo/src/components/shell/InstallButton.test.tsx
  • calricula_pwa_demo/src/components/shell/InstallButton.tsx
  • calricula_pwa_demo/src/components/shell/RepositoryBootstrap.tsx
  • calricula_pwa_demo/src/components/shell/WorkspaceLayout.tsx
  • calricula_pwa_demo/src/components/shell/WorkspaceRuntime.tsx
  • calricula_pwa_demo/src/components/shell/index.ts
  • calricula_pwa_demo/src/lib/ai/chat-persistence.test.ts
  • calricula_pwa_demo/src/lib/ai/chat-persistence.ts
  • calricula_pwa_demo/src/lib/ai/client.test.ts
  • calricula_pwa_demo/src/lib/ai/client.ts
  • calricula_pwa_demo/src/lib/ai/eval-catalog-parity.test.ts
  • calricula_pwa_demo/src/lib/ai/index.ts
  • calricula_pwa_demo/src/lib/ai/schemas.test.ts
  • calricula_pwa_demo/src/lib/ai/schemas.ts
  • calricula_pwa_demo/src/lib/ai/session-readiness.test.ts
  • calricula_pwa_demo/src/lib/ai/session-readiness.ts
  • calricula_pwa_demo/src/lib/ai/session.test.ts
  • calricula_pwa_demo/src/lib/ai/session.ts
  • calricula_pwa_demo/src/lib/ai/types.ts
  • calricula_pwa_demo/src/lib/compliance/ccn.test.ts
  • calricula_pwa_demo/src/lib/compliance/ccn.ts
  • calricula_pwa_demo/src/lib/compliance/citations.ts
  • calricula_pwa_demo/src/lib/compliance/fixtures/python-compliance-service.golden.json
  • calricula_pwa_demo/src/lib/compliance/golden-parity.test.ts
  • calricula_pwa_demo/src/lib/compliance/hours.test.ts
  • calricula_pwa_demo/src/lib/compliance/hours.ts
  • calricula_pwa_demo/src/lib/compliance/index.ts
  • calricula_pwa_demo/src/lib/compliance/rules.test.ts
  • calricula_pwa_demo/src/lib/compliance/rules.ts
  • calricula_pwa_demo/src/lib/compliance/types.ts
  • calricula_pwa_demo/src/lib/data/contracts.ts
  • calricula_pwa_demo/src/lib/data/database.ts
  • calricula_pwa_demo/src/lib/data/hooks.test.tsx
  • calricula_pwa_demo/src/lib/data/hooks.ts
  • calricula_pwa_demo/src/lib/data/index.ts
  • calricula_pwa_demo/src/lib/data/invalidation.test.ts
  • calricula_pwa_demo/src/lib/data/invalidation.ts
  • calricula_pwa_demo/src/lib/data/repository.test.ts
  • calricula_pwa_demo/src/lib/data/repository.ts
  • calricula_pwa_demo/src/lib/domain/ccn-reference.ts
  • calricula_pwa_demo/src/lib/domain/fixture.test.ts
  • calricula_pwa_demo/src/lib/domain/fixture.ts
  • calricula_pwa_demo/src/lib/domain/index.ts
  • calricula_pwa_demo/src/lib/domain/integrity.ts
  • calricula_pwa_demo/src/lib/domain/schemas.ts
  • calricula_pwa_demo/src/lib/pwa/connectivity.test.ts
  • calricula_pwa_demo/src/lib/pwa/connectivity.ts
  • calricula_pwa_demo/src/lib/pwa/course-draft-recovery.test.ts
  • calricula_pwa_demo/src/lib/pwa/course-draft-recovery.ts
  • calricula_pwa_demo/src/lib/pwa/pending-work.test.ts
  • calricula_pwa_demo/src/lib/pwa/pending-work.ts
  • calricula_pwa_demo/src/styles/globals.css
  • calricula_pwa_demo/tailwind.config.ts
  • calricula_pwa_demo/tests/worker/ai-eval-parity.test.ts
  • calricula_pwa_demo/tests/worker/helpers.ts
  • calricula_pwa_demo/tests/worker/quota-harness.ts
  • calricula_pwa_demo/tests/worker/worker.test.ts
  • calricula_pwa_demo/tsconfig.json
  • calricula_pwa_demo/vitest.config.ts
  • calricula_pwa_demo/vitest.worker.config.ts
  • calricula_pwa_demo/worker/api-error.ts
  • calricula_pwa_demo/worker/catalog.test.ts
  • calricula_pwa_demo/worker/catalog.ts
  • calricula_pwa_demo/worker/daily-quota.test.ts
  • calricula_pwa_demo/worker/free-routing.test.ts
  • calricula_pwa_demo/worker/free-routing.ts
  • calricula_pwa_demo/worker/index.test.ts
  • calricula_pwa_demo/worker/index.ts
  • calricula_pwa_demo/worker/json.ts
  • calricula_pwa_demo/worker/quota-protocol.test.ts
  • calricula_pwa_demo/worker/quota-protocol.ts
  • calricula_pwa_demo/worker/tasks.test.ts
  • calricula_pwa_demo/worker/tasks.ts
  • calricula_pwa_demo/wrangler.jsonc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Review on demand using usage pricing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant