Cross-repo change: the Phase 1 backend code landed in 10x-toolkit (52446dd).
This repo holds the change folder + plan; flips Progress rows 1.1-1.4.
- context/changes/bulk-sync-update/{change.md,plan.md,plan-brief.md,research.md}
38 KiB
date, researcher, git_commit, branch, repository, topic, tags, status, last_updated, last_updated_by, last_updated_note
| date | researcher | git_commit | branch | repository | topic | tags | status | last_updated | last_updated_by | last_updated_note | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-06-16T00:00:00Z | mkczarkowski | b52b1be6cc |
master | 10x-cli | Bulk download of all lessons + updating already-downloaded artifacts + verifying what changed upstream |
|
complete | 2026-06-20 | mkczarkowski | Added follow-up backend research (10x-toolkit delivery API + content pipeline). Merged the duplicate `cli-update-all` change into this one. |
Research: Bulk download / update / "what changed" for 10x-cli
Date: 2026-06-16
Researcher: mkczarkowski
Git Commit: b52b1be6cc
Branch: master
Repository: 10x-cli
Research Question
An evangelist reports two related gaps in 10x-cli:
- There is no way to download all modules and lessons at once, nor to
update artifacts already downloaded. Doing a full refresh today means
manually running
list m0,list m1, … thenget m0l1,get m0l2, … lesson by lesson (they scripted exactly that with an agent +--no-course-rules). - Many skills have been updated since the course started, and there is currently no way to verify what changed.
They ask for "an automat in the CLI for this operation." This research maps what exists today, names the precise gaps, and proposes a concrete command design.
Summary
The feature is feasible and mostly sits on top of machinery that already exists — but with one hard constraint that shapes the whole design.
Key findings:
-
The manual
list m0 / list m1 / …loop is unnecessary.10x listwith no argument already calls/api/catalog/:course, which returns all unlocked lessons across all modules in a single response (catalog.lessons). Locked-module lessons are deliberately omitted from that array. So enumeration of "everything fetchable" is already a one-call operation. (list.ts:46-48, api-content.ts:104-113) -
Re-apply is already safe and idempotent.
applyBundle()does three-way SHA-256 conflict detection: a clean upstream change is applied, an unchanged file is left alone, and a user-edited file triggers the conflict resolver (which defaults toskipin non-TTY / pipeline mode — user work is never silently destroyed). A bulk "update everything" loop inherits all of this for free. (writer.ts:445-464, conflict-prompt.ts:4-40) -
The manifest already stores per-file content hashes (SHA-256) for skills and prompts — this is the substrate for a "what changed" report. (manifest.ts:20-22, 24-29)
-
HARD CONSTRAINT — the manifest alone cannot tell you upstream changed. The stored hash is the hash of the last-downloaded content. The API bundle carries no etag / version / revision, the catalog endpoint exposes no per-lesson hash, and there is no conditional-request (
If-None-Match) support. Therefore detecting "did this skill change upstream?" requires re-fetching the full lesson bundle and comparingcontentHash(bundle.content)against the stored manifest hash. A sync is inherently an N×-fetch loop, one HTTP round-trip per lesson, with no cheap "has anything changed?" pre-check. (api-content.ts:83-93, api-types.ts) -
Change visibility is limited to skills + prompts. Configs are not hashed (skip-on-exists only) and rules are not manifest-tracked at all (sentinel-block managed). A "what changed" report cannot cover configs or rules without new mechanism. (writer.ts:324-331, 279-321)
-
This is greenfield. A bulk/update/sync command was never proposed, deferred, or ruled out. The cumulative-manifest work deliberately kept
lessons+ per-lessonappliedAtto "future-proof10x status" — exactly the foundation a sync/diff command needs. (cumulative-manifest/plan-brief.md:26)
Bottom line: a 10x sync command = fetchCatalog → filter to unlocked
lessons → loop fetchLesson + applyBundle per lesson → aggregate the per-file
actions into a single change summary. The risky parts (clobbering user edits,
cross-lesson deletion) are already solved. The new work is: multi-lesson
orchestration, partial-failure handling, an aggregate change report, and a
read-only "preview/diff" mode.
Detailed Findings
Area 1 — Enumeration: the catalog already lists everything
10x list (no module arg) calls fetchCatalog(course, token) which hits
GET /api/catalog/:course (list.ts:46-48,
api-content.ts:104-113).
CatalogResponse (api-content.ts:37-41) is:
{ course: string; modules: ModuleSummary[]; lessons: LessonSummary[] }
lessonsis a flat array spanning all modules, each item keyed to a module only bylesson.module: number. There is no per-lessonstate/lockedfield — lock status lives onmodules[].effectiveState("locked" | "unlocked", api-content.ts:18-24).- Locked-module lessons are omitted from
catalog.lessons. Confirmed by the test fixture: a locked module 3 has zero entries inlessons[]and the CLI assertslessonCount === 0for it (tests/list-command.test.ts:126-180, 406). Contrast:/api/modules/:course/:moduledoes return lesson rows for locked modules, but the CLI blanks them inrenderModuleDetailto avoid leaking titles (list.ts:194-227).
Implication: to enumerate everything fetchable, one fetchCatalog call is
enough; iterate catalog.lessons. To explain why something was skipped, read
catalog.modules[].effectiveState. The evangelist's per-module list mN loop
duplicates data already in the single catalog call.
Area 2 — The manifest: what is stored (and what is not)
Defined in manifest.ts:38-59, MANIFEST_VERSION = 3,
stored at <manifestDir>/.10x-cli-manifest.json.
lessons: Record<string, LessonFilesEntry>(manifest.ts:31-36) records per-lesson ownership:appliedAt(ISO-8601) + the skills/prompts/configs that lesson owns.Object.keys(manifest.lessons)enumerates every applied lesson — this is the iteration set for an "update what I already have" sweep.filesis a recomputed union of all lesson entries (buildUnionFiles, manifest.ts:158-182); it carries the content hashes.- Content hashes (manifest.ts:20-22):
SHA-256hex over the raw UTF-8 string content, no normalization. Both sides hash identically (bundle content on write,readFileSync(path,"utf8")on read), so they are directly comparable.- Skills →
files.skills[name].contentHashes[relPath] - Prompts →
files.promptHashes["<name>.md"] - Configs: NOT hashed (skip-on-exists only, writer.ts:324-331)
- Rules: NOT manifest-tracked (sentinel-block managed, writer.ts:279-321)
- Skills →
- No per-artifact upstream version / etag / revision is stored — only the content hash of the last applied content. The hash is the only fingerprint of "what was last downloaded."
Area 3 — The CRITICAL constraint: detecting upstream change requires a fetch
There are two distinct questions:
(A) "Does local on-disk content differ from what I last downloaded?" —
answerable offline. Read the file, hash it, compare to the stored manifest
hash. This is exactly localHash === storedHash at
writer.ts:458-460. (Skills + prompts only.)
(B) "Did the latest upstream content change since I downloaded it?" — NOT
answerable without re-fetching. The manifest holds only the last-downloaded
hash; the LessonBundle from the API (api-content.ts:83-93)
carries no version/etag/hash on the bundle envelope, and GET /api/catalog
exposes no per-lesson hash. A grep of the generated OpenAPI types for
etag|version|modified|revision|since|if-none-match|last-modified finds only a
per-artifact contentHash? inside the full lesson-bundle response
(api-types.ts:116/122/128/134) — not on the cheap
catalog. There is also no bulk/diff endpoint (only a per-lesson /download
ZIP variant the CLI never calls).
Consequence: to report "this skill changed upstream," you must download the
bundle and compute contentHash(bundle.content) !== storedHash. A sync is an
N×-fetch loop; there is no way to skip unchanged lessons before downloading them.
With a fetched bundle, a full three-way state per file is computable (mirroring computeFileAction, writer.ts:445-464):
storedHash= last downloaded (manifest)localHash= on disk now (re-hash file)upstreamHash=contentHash(bundle.content)(re-hash fetch)
Then: upstreamHash !== storedHash ⇒ upstream changed;
localHash !== storedHash && localHash !== upstreamHash ⇒ user edit / conflict;
localHash !== storedHash && localHash === upstreamHash ⇒ already up to date.
Area 4 — The writer: re-apply safety is already solved
The full decision table in computeFileAction (writer.ts:445-464):
| File exists? | local == bundle? | storedHash? | local==stored? | action | isConflict | Meaning |
|---|---|---|---|---|---|---|
| No | — | — | — | created |
false | new file |
| Yes | yes | — | — | unchanged |
false | no change |
| Yes | no | yes | yes | updated |
false | clean upstream update |
| Yes | no | yes | no | updated |
true | user-edit conflict |
| Yes | no | no (v2/untracked) | — | updated |
true | one-time calibration conflict |
Final per-file action values (writer.ts:47-55): created,
updated, unchanged, skipped, removed, conflict_overwritten,
conflict_saved_user, conflict_skipped.
- The
ConflictResolver(conflict-prompt.ts:4-40) returns"overwrite" | "save_user" | "skip"; non-TTY →skipunconditionally (conflict-prompt.ts:8).conflict_skippeddeliberately keeps the old stored hash so the conflict re-triggers next time (writer.ts:213,267). --dry-runalready computes identical actions without writing — every fs mutation is behind a!dryRunguard, and the fullWriteResultis returned regardless (writer.ts:201-362,432-438). So aget --dry-runis already a per-lesson "what would change" preview.- Caveat: in TTY mode
onConflictisawaited before the dry-run guard (writer.ts:189-197), so a TTY dry-run would interactively prompt even though nothing is written. A clean preview needs a non-prompting resolver injected (or non-TTY, which auto-skips).
- Caveat: in TTY mode
- Cross-lesson deletion is already prevented:
computeRemovalsis scoped to the current lesson and protects files owned by other lessons (writer.ts:498-600).
Area 5 — What is missing for a bulk command
Reusable as-is:
fetchCatalog+fetchLesson(with mandatory bundle-signature verification — do not bypass these wrappers),applyBundle,createConflictResolver,readManifest,resolveToolProfile,readToolConfig/updateToolConfig.- The cumulative, per-lesson manifest as the iteration set + change baseline.
requireAuth(ctx)called once covers the whole batch (handles refresh + cross-process lock) (auth-guard.ts:119).
Needs building:
- Multi-lesson orchestration.
applyBundleis strictly single-bundle andget.tsdoes one fetch per invocation. A sync must loop, callingapplyBundleper lesson (the cumulative manifest accumulates correctly across calls). - An aggregate change summary. Nothing today produces "3 changed upstream,
1 user-edited, 5 unchanged." The
countsin get.ts:513-520 are artifact-bucket sizes, not change tallies. The classification must be derived from per-file actions (created+updated= applied upstream change;conflict_*= user-edited;unchanged;removed). - A read-only preview without consuming the resolver.
computeFileActionis module-private and there is no exposed{action, isConflict}signal. The cleanest enabler is a new exportedplanBundle()(or exportingcomputeFileAction) that returns per-file actions + conflict flags without invokingonConflictand without writing — this powers a--dry-run/diff mode that never prompts. - Partial-failure handling.
handleLessonErrorcallsprocess.exit(get.ts:385-468); reused directly it would abort the whole sweep on the first 403/404/network error. Sync needs its own accumulator that records per-lesson outcomes and emits one aggregate envelope at the end. - Concurrency / rate-limit policy.
api-client.tshas no retry, no client-side rate-limiting, no concurrency control; a429surfaces ascode: rate_limitedand bubbles up (api-client.ts:109). A bulk loop should be sequential or a small bounded pool, and thread a single sharedAbortSignalso Ctrl-C cancels the batch (per-call 30s timeout already exists, api-client.ts:84,163).
Area 6 — Historical context (from prior changes)
- Bulk/update/sync was never proposed, deferred, or ruled out in any archived change, the README, or the CHANGELOG. This is open design space.
- The only adjacent decisions: a
--keep/--no-cleanupflag was explicitly rejected (content-overwrite/research.md:208-210), and a10x cleancommand was deferred as future work (cumulative-manifest/plan.md:35). The team's instinct was structural over flag-based — a sync feature should follow that taste and lean on the manifest model. - The cumulative manifest (cumulative-manifest/change.md:17)
was built to fix cross-lesson data loss, but deliberately kept
lessons+ per-lessonappliedAtto "future-proof10x status" (cumulative-manifest/plan-brief.md:26) — the exact foundation for a status/diff/sync command. - User-edit-protection / content-overwrite (user-edit-protection/change.md:18-21) established the three-way hash detection and non-TTY skip-default. A bulk update inherits safe re-apply for free — the hard part of "update without destroying work" is done.
- The master design plan referenced by CLAUDE.md
(
thoughts/shared/plans/2026-04-07-10x-cli-design.md) is NOT present in this repo — it lives in the sibling10x-toolkitrepo. Before finalizing, confirm there whether a bulk/update command was already slotted into a later phase.
Proposed Design (state + gap → recommendation)
A single new command, 10x sync, plus a small writer refactor to enable a
non-prompting preview.
Command shape
10x sync [--dry-run] [--module mN] [--course <slug>] [--tool <tool>]
[--lang <lang>] [--no-course-rules] [--json] [--verbose]
Two complementary modes (driven by whether a manifest exists / by intent):
- Update mode (default): for every lesson in
Object.keys(manifest.lessons), re-fetch and re-apply, accumulating a change report. This directly answers "update already-downloaded artifacts" + "what changed." - Full-download mode (
--all, or sync with no manifest): enumeratecatalog.lessons(unlocked only), fetch + apply each in module/lesson order. This answers "download all modules and lessons at once."
--dry-run makes it a read-only diff: report what changed upstream and what
would conflict, write nothing. This is the "verify what changed" answer.
Algorithm
requireAuth(ctx)once;resolveContext(options);resolveToolProfile.fetchCatalog→ build the unlocked-lesson set. For update mode, intersect withObject.keys(manifest.lessons); for full mode use all unlocked lessons. Record locked/excluded lessons with reasons (fromeffectiveState).- Sequentially (or bounded pool) for each lesson:
fetchLesson(keeps signature verification) → classify per file via the new read-only planner → if not dry-run,applyBundlewith the standard conflict resolver. - Accumulate per-lesson outcomes; never
process.exitmid-loop. Map per-file actions to a summary:upstream-updated,created,unchanged,user-edited(conflict_*),removed, plus per-lesson fetch errors. - Emit one aggregate envelope (human table + JSON
{ lessons: [...], totals: {...} }) and choose an exit code from the worst outcome (0clean /1if any fetch errored / unchanged still0).
Required refactor
Export a pure, non-mutating planner from the writer — e.g.
planBundle(bundle, projectRoot, { profile }): WritePlan — that returns per-file
{ path, action, isConflict, upstreamChanged } without invoking onConflict
and without writing. applyBundle would consume the same planner so behavior
can't drift. This powers --dry-run diff without the TTY-prompt caveat and gives
sync its change classification. (writer.ts:445-464)
Known limitations to document for the user
- "What changed" covers skills and prompts only — configs aren't hashed and rules aren't manifest-tracked, so changes to those cannot be reported by hash.
- Every sync re-downloads every targeted lesson (no server-side change check);
expect N HTTP round-trips. A future server-side etag/
If-None-Matchor a catalog-level per-lesson hash would make this cheap — worth raising with the backend team (sibling10x-toolkitrepo).
Code References
src/commands/list.ts:46-48—list(no arg) →fetchCatalog(full catalog in one call)src/commands/list.ts:138-156, 194-227— catalog/module rendering; locked-module blankingsrc/lib/api-content.ts:37-41—CatalogResponse(flatlessons[], no per-lesson state)src/lib/api-content.ts:83-93—LessonBundle(no version/etag on the envelope)src/lib/api-content.ts:104-149—fetchCatalog/fetchLessonwrappers (+ signature verify)src/lib/manifest.ts:20-22—contentHash(SHA-256 over raw UTF-8)src/lib/manifest.ts:24-36, 38-59— manifest schema,LessonFilesEntry,lessonsrecordsrc/lib/manifest.ts:158-182—buildUnionFilessrc/lib/writer.ts:445-464—computeFileAction(three-way detection; module-private)src/lib/writer.ts:47-55— fullArtifactActionunionsrc/lib/writer.ts:189-197, 201-362— conflict await + dry-run guardssrc/lib/writer.ts:498-600—computeRemovals(lesson-scoped, protected set)src/lib/conflict-prompt.ts:4-40—ConflictResolver, non-TTY skip defaultsrc/commands/get.ts:385-468—handleLessonError(callsprocess.exit— must not be reused raw in a loop)src/commands/get.ts:497-522— JSON envelope +counts(bucket sizes, not change tallies)src/lib/api-client.ts:84,109,163— 30s timeout,rate_limitedmapping, no retry/concurrencysrc/lib/auth-guard.ts:119—requireAuth(call once per batch)src/index.ts:4-29— command registration wiring + unknown-option → exit 2src/generated/api-types.ts:7,74,116,173,255,323,382— endpoint catalog; per-artifactcontentHashonly inside bundle; no etag/diff/bulktests/list-command.test.ts+tests/helpers/api-content-mock.ts— test harness to copy for a sync command
Architecture Insights
- The catalog is the cheap enumeration primitive; the bundle is the only change oracle. Lock status and lesson lists are cheap (one catalog call); actual content change can only be known by downloading the bundle and re-hashing.
- Hashes are local-only fingerprints, not upstream revisions. Everything about change detection follows from this: it's a fetch-then-compare model, not a conditional-GET model.
- Re-apply is intentionally idempotent and pipeline-safe. The non-TTY skip-default and lesson-scoped removals mean a bulk loop is safe by construction — the design risk is orchestration/UX, not data safety.
updatedis overloaded (clean update vs. pre-resolution conflict); the conflict distinction only becomes visible after the resolver runs. A read-only planner that surfacesisConflictdirectly removes this ambiguity for reporting.
Historical Context (from prior changes)
context/archive/2026-05-27-cumulative-manifest/— cumulative manifest;lessons+appliedAtkept to future-proof10x status(the sync foundation).context/archive/2026-05-27-user-edit-protection/— three-way hash detection, non-TTY skip-default (safe re-apply inherited by sync).context/archive/2026-05-27-content-overwrite/—--keepflag explicitly rejected in favor of structural fix.context/archive/2026-06-02-disable-course-rules/—--no-course-rules/ persistedcourseRulesconfig; sync must honor the same tri-state resolution.
Related Research
- None under
context/changes/**/research.mdyet (this is the first change incontext/changes/). Prior research lives only incontext/archive/**.
Open Questions
- Does the sibling
10x-toolkitmaster plan (thoughts/shared/plans/2026-04-07-10x-cli-design.md) already scope a bulk/update command or a10x status? Must be checked there — not present in this repo. - Server-side cheap change check. Should we ask the backend team to add a
per-lesson
contentHash/updatedAtto the catalog response, orIf-None-Matchsupport on/api/lessons/...? That would turn an N×-download sync into an N×-cheap-check + download-only-changed. This is the single biggest efficiency lever and is a backend (10x-toolkit) decision. - Update scope default. Should
10x syncdefault to updating only already-applied lessons (manifest.lessons) or to downloading all unlocked lessons? (Recommendation: update-applied by default,--allfor full download.) - Config/rules change visibility. Out of scope for a hash-based report. Do we want to start hashing configs (and possibly tracking the rules block) so future syncs can report those too?
- Exit-code semantics for a partial batch (some lessons updated, one network
error) — needs a defined policy consistent with
ExitCodes.
Follow-up Research 2026-06-20 — the backend side (10x-toolkit)
The original research (above) deliberately deferred the backend to Open Question 1
& 2: it concluded the CLI must do an N×-download sync because the catalog carries
no per-lesson change signal, and flagged "ask the backend team whether a cheap
change-check can be added" as the single biggest efficiency lever. This follow-up
answers that by reading 10x-toolkit/packages/api (delivery worker),
packages/course-content (build/transform pipeline) and packages/ai-artifacts
(source content). It updates the recommendation and resolves Open Questions 1, 2, 4.
This change was investigated under the throwaway id cli-update-all; that folder has
been merged into bulk-sync-update (this one) — same work, started ~Jun 16 and
picked back up Jun 20. The feature spans two repos (10x-cli + 10x-toolkit).
Headline finding — a per-artifact content hash ALREADY EXISTS in published bundles
The earlier research said "the manifest hash is a local-only fingerprint; the API bundle carries no version/etag." That is true of the envelope and the catalog, but not of the published artifacts. The content pipeline already computes a SHA-256 per artifact and bakes it into the bundle JSON served from R2:
- The TypeScript build (
buildLessonBundle,packages/course-content/src/build/core.ts:122-158) does not setcontentHash— which is why a naive read of the bundle builder (and Open Question framing) missed it. But a second, post-build CI step does: packages/course-content/scripts/transform-content.mjs:100-101computessha256(SYSTEM_PROMPT_VERSION + ":" + content)per artifact (skillSKILL.md, prompt, rule), uses it to cache LLM "universalization"/translation, and writescontentHashinto the bundle JSON (transform-content.mjs:366-374, 410-419).- The schema already has the slot:
namedContentSchema/skillBundleSchemadeclare optionalcontentHash(packages/course-content/src/schemas/bundle.ts:6-8, 20-21), and the CLI's generated types already see it on the lesson bundle artifacts (src/generated/api-types.ts:116,122,128,134). - CI publishes by overwriting the R2 objects in place
(
10x-toolkit/.github/workflows/ci.yml:276-306,wrangler r2 object put --remote).
So the published bundle for every lesson already contains a stable-ish per-artifact hash — it's just buried inside the full lesson bundle, the exact thing a cheap "what changed" pre-check wants to avoid downloading.
Caveat that shapes the design: that hash mixes in SYSTEM_PROMPT_VERSION
(transform-content.mjs:98) and is computed on the transformed artifact, so it
varies with the prompt version and potentially per language variant
({lessonId}.json vs {lessonId}.pl.json). It is a fingerprint of "published
content as transformed," not of raw source — which is fine for change detection
(that's exactly what a learner wants: "did what I'd download change?") as long as the
hash we expose corresponds to the variant the CLI fetches.
Delivery API: per-endpoint change-detection audit
The API is a Hono Cloudflare Worker (packages/api/src/index.ts:39-56), all delivery
routes JWT-guarded, reading content live from R2 (COURSE_10X3_CONTENT_BUCKET)
on every request — no in-memory cache, no R2 metadata read.
| Endpoint | Returns | Change signal today | Conditional req. |
|---|---|---|---|
GET /api/catalog/:course (catalog.ts:30-31) |
{course, modules[], lessons[{lessonId,module,lesson,title,summary,bundlePath,availableLanguages?}]} (catalog.ts:14-27) |
none — only releaseAt (release schedule, not content mtime) |
none |
GET /api/modules/:course[/:module] (modules.ts:30,57) |
enriched modules; single-module adds lessons[{lessonId,lesson,title,summary,availableLanguages}] (modules.ts:14-24) |
none beyond releaseAt |
none |
GET /api/lessons/:course/:lessonId (lessons.ts:20-22) |
full bundleSchema (bundle.ts:41-51): skills/prompts/rules/configs, each with optional contentHash |
per-artifact contentHash inside the bundle, plus X-Bundle-Content-Hash header (see below) |
none |
GET /api/lessons/:course/:lessonId/download (download.ts:23-25) |
ZIP or raw markdown | none (doesn't even sign) | none |
Two hashes exist server-side and must not be conflated:
X-Bundle-Content-Hashresponse header (lessons.ts:153-161,signing.ts:23-27,37) — SHA-256 of the entire post-transform JSON response body, emitted only whenBUNDLE_SIGNING_KEYis set, for tamper verification (Ed25519 signing). It is computed at request time and varies per?tool=and?lang=variant, so it's not a stable per-lesson identity and reading it still costs a full bundle fetch + stringify. Not a viable cheap pre-check.- Per-artifact
contentHashbaked at build time (above) — the real reusable substrate, but only present inside the full bundle.
No endpoint supports If-None-Match / If-Modified-Since / a since param; grep for
etag|If-None-Match|updatedAt|revision across the API src returns nothing
(confirmed CLI-side too: none in src/generated/api-types.ts).
Resolved: the smallest backend change (answers Open Q1 & Q2)
The earlier research's "ideal but expensive" wish — a catalog-level per-lesson hash — turns out to be cheap to ship, because the hashes already exist at build time and the catalog is built in the same run:
Recommended (Option 1): surface a per-lesson digest on the catalog.
course-contentbuild: inbuildCatalog(packages/course-content/src/build/core.ts:160-183) — which already has every built bundle in hand — compute a per-lesson digest (e.g.sha256over the lesson's ordered artifactcontentHashes, or over the canonical bundle JSON) and write it onto each catalog lesson. AddcontentHash: z.string()tocatalogLessonSchema(packages/course-content/src/schemas/catalog.ts:10-18). This must run aftertransform-content.mjspopulates artifact hashes, or recompute from the transformed bundles.- API: widen
enrichedCatalogSchema(packages/api/src/routes/catalog.ts:18-25) to includecontentHash. The handler already spreadscatalog.lessonsraw (catalog.ts:79), so the field flows through with no handler logic change. - CLI:
fetchCatalogalready returns the catalog in one call (src/lib/api-content.ts:104-113); addcontentHash?toLessonSummary(api-content.ts:27-35) and10x synccompares it against the locally stored manifest hash, downloading only lessons whose digest moved.
Net effect: the N×-download sync from the original research collapses to one
catalog GET + download-only-changed. Both feature requirements — bulk download
(via existing bundlePath) and "what changed" (via the digest) — are satisfied by a
single new catalog field. This is the single highest-leverage change and it's small.
Variant correctness (the one trap): the digest published on the catalog must
match the variant the CLI will fetch. Options: (a) publish a single canonical/base
digest and accept that a ?tool/?lang transform could differ — only acceptable if
the change-detection compares against a same-variant baseline; or (b) publish a small
map contentHash keyed by the variant axes the CLI actually uses. Given the CLI
fetches with {tool, lang} (fetchLesson, api-content.ts:138-195) and the manifest
stores per-file hashes of what was written for that tool/lang, the cleanest contract
is a per-lesson digest over the base/Claude-Code variant with the explicit
understanding that lang/tool-only differences are out of scope for the cheap check
(the existing three-way writer detection still catches them on actual download).
Alternatives, both strictly worse for a bulk sync (documented for completeness):
- Option 2 —
If-None-Match/ETag on the lesson endpoint (lessons.ts:151-184): per-lesson, N round-trips, and to answer 304 cheaply you still need the hash without reading the body → pushes you back to storing it in the catalog or R2 custom metadata (no code reads R2.etag/.uploadedtoday). - Option 3 — new
GET /api/manifest/:coursereturning[{lessonId, contentHash}]: functionally a subset of Option 1, extra route + registration (index.ts:51-56); only worth it to avoid widening the catalog payload.
Course / module / lock-state definition (for the sync enumeration set)
- Course id is
10xdevs3(packages/course-content/src/courses/10xdevs3/index.ts:34-44): module 0 = 1 lesson (m0l1); modules 1–5 = 5 lessons each (m{N}l1..m{N}l5). - Per-module definition
{module, title, releaseAt, stateOverride}(packages/course-content/src/schemas/module.ts:3-8; e.g.courses/10xdevs3/module-01/index.ts:3-8). - Lock resolution:
resolveModuleState(releaseAt, stateOverride, now)(packages/api/src/lib/module-state.ts:3-11) —stateOverridewins, elsereleaseAt <= now ? unlocked : locked; a runtime KV override (stateOverride:${course}:${module}) sits on top (packages/api/src/routes/download.ts:109). Catalog omits locked-module lessons (confirmed CLI-side in the original research); this is the enumeration set for--allfull-download mode.
Historical: bulk/sync is an explicit NON-GOAL in the master plan (important)
The master design plan the original research couldn't locate (it guessed
thoughts/shared/plans/...) is at
10x-toolkit/context/archive/2026-04-07-10x-cli-design/plan.md (the legacy
thoughts/ dir was migrated to context/ on 2026-04-29). It scopes only four v1
commands — auth, get, list, doctor — and the "What We're NOT Doing" section
explicitly lists:
- "Offline mode / local caching of lesson content" (
plan.md:62) - "Background sync or auto-update of applied artifacts" (
plan.md:63)
So a sync/update-all/status command is net-new scope that reverses a stated
non-goal — worth a deliberate "yes, we're doing this now" decision in the plan, not
just an implementation detail. (Note: every other sync/bulk hit in 10x-toolkit
refers to Circle membership sync into KV — server-side, unrelated. Don't let the
word collide.) The per-lesson ZIP download endpoint
(2026-04-12-10x-cli-expansion-wave1) is the closest existing primitive and is still
one-lesson-per-call.
Updated recommendation
The original 10x sync design (update-applied-by-default, --all full download,
--dry-run read-only diff, planBundle() writer refactor) stands. The backend
research changes one thing and adds one coordination requirement:
- Pursue the catalog
contentHash(Option 1) as part of this work, not as a "future efficiency lever." It is small, the hash substrate already exists, and it converts the sync from "always re-download everything" into "show what changed for the price of one request, download only the deltas." Without it the CLI command still works but is an N×-download every time. - This is a coordinated two-repo change. Sequence: (a)
10x-toolkitcourse-contentbuild emits per-lessoncontentHash→ (b)10x-toolkitapicatalog surfaces it → (c) regenerate10x-cli'ssrc/generated/api-types.tsagainst the updated/openapi.json→ (d) build10x syncconsuming it. The CLI command can ship first against the no-hash catalog (full re-download fallback) and gain the cheap pre-check once the backend field lands — so the two repos don't have to land atomically.
Resolution of original Open Questions
- Master plan scope → Resolved: found at
10x-toolkit/context/archive/2026-04-07-10x-cli-design/plan.md; bulk/sync is an explicit non-goal, so this is a deliberate scope addition. - Server-side cheap change check → Resolved & recommended: add per-lesson
contentHashto the catalog (Option 1). The hash already exists at build time; cost is ~one field. Strongly recommended to include in this change. - Update scope default → Unchanged recommendation: update
manifest.lessonsby default,--allfor full unlocked download. - Config/rules change visibility → Still out of scope for a hash report (configs not hashed, rules sentinel-managed). The catalog digest covers skills+prompts; if we ever want config/rule change reporting, the per-artifact build hashes exist for those too and could be folded into the lesson digest later.
- Exit-code policy for partial batch → Still open; a CLI plan-time decision.
New backend code references
10x-toolkit/packages/course-content/scripts/transform-content.mjs:98,100-101,366-374,410-419— build-time per-artifactcontentHash(mixesSYSTEM_PROMPT_VERSION), written into bundle JSON10x-toolkit/packages/course-content/src/build/core.ts:122-158(buildLessonBundle, omits hash),:160-183(buildCatalog, where a per-lesson digest would be added)10x-toolkit/packages/course-content/src/schemas/bundle.ts:6-8,20-21— optionalcontentHashslot on artifacts10x-toolkit/packages/course-content/src/schemas/catalog.ts:10-18—catalogLessonSchema(addcontentHashhere)10x-toolkit/packages/course-content/src/courses/10xdevs3/index.ts:34-44— course/module/lesson map10x-toolkit/packages/api/src/routes/catalog.ts:14-27,30-31,79— catalog handler +enrichedCatalogSchema(widen here; already spreads lessons)10x-toolkit/packages/api/src/routes/lessons.ts:151-184,153-161— bundle assembly +X-Bundle-Content-Hash(request-time, variant-specific)10x-toolkit/packages/api/src/lib/signing.ts:23-27,33-45—sha256Hex/signBundle(integrity, not change-detection)10x-toolkit/packages/api/src/lib/module-state.ts:3-11— lock resolution10x-toolkit/packages/api/src/index.ts:39-56— route registration / JWT guard10x-toolkit/.github/workflows/ci.yml:262-307— transform + R2 publish pipeline10x-toolkit/context/archive/2026-04-07-10x-cli-design/plan.md:57-67,582-586,1185-1296— v1 command scope; bulk/sync as non-goal
New open questions
- Where does the per-lesson digest get computed — extend
buildCatalog(TS, but it doesn't currently have transformed hashes) or the post-buildtransform-content.mjs(which does)? The hash must reflect the transformed published content, so the transform script (or a step after it) is the natural home. - Variant policy for the published digest — single base-variant digest vs. a
per-
{tool,lang}map. Needs a one-line contract decision before implementing the catalog field (see "Variant correctness" above). - Do we relax the
BUNDLE_SIGNING_KEYcoupling? The only request-time hash today is gated on signing; the catalog digest must be unconditional. (Option 1 sidesteps this — the digest is a published field, not a signing header.)