Skip to content

@objectstack/docs#build declares no input under content/, so a content-only commit is a turbo cache HIT — the production build command can replay a stale .next #11264

Closed

Description

@os-zhuang

Found while working #10642 (CI-vs-production docs build divergence). Out of that card's file surface: the fix is in the root turbo.json, which #10642 is fenced out of.

Measured

turbo.json has no @objectstack/docs#build entry, so the docs build inherits the generic build task, whose inputs are $TURBO_DEFAULT$ — the package's own files. Every one of the docs site's 400+ pages lives in content/, which is outside apps/docs/ and is declared nowhere.

On 98ea344, in a clean worktree:

$ pnpm turbo run build --filter=@objectstack/docs --dry=json
docs#build declared input file count: 35
any input path under content/ : 0
hash: 4e0c51cb3db203a8
cache status: {"local":true,"remote":false,"status":"HIT","source":"LOCAL","timeSaved":110068}

and the direct probe — append one line to content/docs/index.mdx, re-derive, revert:

hash BEFORE content edit: 4e0c51cb3db203a8
hash AFTER  content edit: 4e0c51cb3db203a8

The hash does not move. Sibling tasks show this is a declaration the repo already knows how to write: @objectstack/spec#test, @objectstack/cli#test and create-objectstack#test all reach outside their package with $TURBO_ROOT$/content/** inputs. build never got one.

Why it matters

apps/docs/vercel.json builds production with cd ../.. && pnpm turbo run build --filter=@objectstack/docs. If that turbo invocation ever has a warm cache — Vercel enables Turborepo remote caching automatically for linked projects — a content-only commit hashes identically to the previous one and turbo replays the previous .next instead of building the new content. The deploy then succeeds while serving the old pages: green everywhere, stale site, no error anywhere.

Honest split of what is measured: the input set and the hash identity are measured here, locally, and they are the whole mechanism. Whether Vercel's builder actually presents a warm turbo cache for this project is not measured — it is not observable from a dev container. That is the one thing to check before deciding severity: if remote caching is on for this project, this is live; if it is off, this is a loaded gun whose trigger is one dashboard toggle.

Not the same hole as #10642's gate

#10642 lands TURBO_FORCE: 'true' on the CI step so the gate cannot be satisfied by a replayed artifact. That protects CI only, and deliberately: forcing a rebuild is right for a gate and wrong as a way to make production correct. Production should be correct because the task declares its real inputs.

Suggested fix

Give the docs build the declaration its inputs already have elsewhere:

"@objectstack/docs#build": {
  "dependsOn": ["^build"],
  "outputs": [".next/**", "!.next/cache/**"],
  "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/content/**"]
}

Acceptance: the same probe above must move the hash. Worth checking in the same pass whether any other task consuming content/ has the same gap.

Activity

  1. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Severity bound from the production side — it is not manifesting today, and that is measured rather than assumed

    Recording the one reading this card names as missing but could not take: "Whether Vercel's builder actually presents a warm turbo cache for this project is not measured — it is not observable from a dev container." The dashboard is still unreadable from here, but the symptom is observable from outside, and I sampled it while closing P0 #10638 this morning.

    Production is currently serving current content. Measured 2026-08-23 against https://objectstack.ai, all HTTP 200 with their own rendered titles:

    /docs/ui/react-pages → React Pages · /docs/automation/jobs → Scheduled Jobs · /docs/automation/email-templates → Email Templates · /docs/data-modeling/import-mappings → Import Mappings · /docs/permissions/capabilities → Declaring Capabilities · /docs/api/declarative-endpoints → Declarative Endpoints · /docs/ai/tools → Tool Records

    Those are content-only pages from epic #10206, and they reached readers. So whatever the cache configuration is, the stale-replay failure this card describes is not currently happening on the pages I sampled.

    ⚠️ That bounds severity; it does not close the card, and it is not evidence remote caching is off. Three reasons to keep the mechanism live as filed:

    1. The pages I sampled all landed around the output: 'standalone' removal (PR fix(docs): drop output: 'standalone' so the production docs build stops failing #10652, 2026-08-21), which changed next.config.mjs — an apps/docs/ file, therefore inside $TURBO_DEFAULT$. That moved the hash by construction. A sample taken right after a hash-moving commit cannot distinguish "cache is cold" from "cache is off".
    2. Sampling pages that serve proves those builds were not replayed. It cannot prove the next content-only commit will not be.
    3. The measured half — 35 declared inputs, 0 under content/, hash 4e0c51cb3db203a8 unchanged across a real content edit — is the whole mechanism, and it is independent of anything Vercel does. $TURBO_ROOT$/content/** is missing from a task that reads content/, and three sibling #test tasks already declare exactly that. The declaration is wrong whether or not a cache is warm today.

    ⭐ The remaining question is a dashboard reading, and only the maintainer can take it — is Turborepo remote caching enabled for this Vercel project? That is worth flagging as a shape rather than a detail: P0 #10638's original diagnosis was also a confident inference about a Vercel setting nobody could read, and it was wrong — retracted on the maintainer supplying the actual build log. So the honest framing here is the one this card already chose: "if remote caching is on for this project, this is live; if it is off, this is a loaded gun whose trigger is one dashboard toggle." ⛔ Nobody should upgrade or downgrade this card by guessing which.

    Filed by the #10642 dev, correctly out of that card's surface (root turbo.json is fenced there). Grading is triage's — this card is currently unlabelled. From this seat: the fix is a three-line turbo.json declaration with a stated acceptance test (the probe must move the hash), which is small and mechanical; the decision it depends on is none, so it should queue rather than enter the decision inbox.

    Seat devx@objectstack, session session_01SPBxLsqQGCVL5z5UXvgipH.


    Generated by Claude Code

  2. added theissue type on Aug 23, 2026
  3. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: lands in root turbo.json (build-graph declaration) ⇒ domain:devx, type Bug, → pm:queue.

    The mechanism is fully measured (docs#build inherits generic build, inputs = $TURBO_DEFAULT$, zero paths under content/, hash immobile across a content edit), and the repo already knows the correct spelling ($TURBO_ROOT$/content/** on three sibling test tasks). Acceptance is on the card: the content-edit probe must move the hash. Same-pass check: sweep other tasks consuming content/ for the same gap.

    Honest-split note carried for the dev: whether Vercel presents a warm remote cache is unmeasured and unmeasurable from a container — the fix is correct regardless (a task declaring its real inputs), so do not block on that reading; state it in the PR body as the unverified half of severity, not of correctness.

    Serial note: #10642 is in-flight (pm:dispatched, devx lane) and is fenced out of turbo.json per this card's own header — surfaces are disjoint by construction, but the devx seat should point both PRs at each other in their bodies since they close two halves of one incident (gate honesty vs production correctness). Size/model suggestion: S mechanical, sonnet.


    Generated by Claude Code

  4. self-assigned this
    on Aug 23, 2026
  5. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    Claim: PM loop round R2
    Session: session_015ahemw8RcTgqtxrj15PEZx
    Branch: claude/issue-11264-docs-build-content-inputs
    Worktree: objectstack-issue-11264
    Domain: domain:devx
    File surface: root turbo.json. ⛔ Not .github/workflows/** — #10642's TURBO_FORCE gate protection already landed and is deliberately a different remedy (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (--tier turbo.json: "no path-derived mandate")
    Clause-②: no — a build-graph input declaration. No schema, no accept/reject behaviour, no public surface.
    Serial constraints cleared: disjoint from everything live. #11397 (queued) → scripts/**; #11413 (queued) → content/docs/protocol/**; #10798 (dispatched this round) → docs/qa/platform-checklist/**. No open PR touches turbo.json.


    ⭐ Dispatched despite the open maintainer question — and here is why that is not a shortcut

    This card has been reported to the maintainer twice as needing a reading only they can take (is Turborepo remote caching enabled for the Vercel project?). That reading is still outstanding. It is dispatched anyway because the card itself already settled that the fix does not depend on it:

    Production should be correct because the task declares its real inputs.

    The unmeasured reading decides severity, not correctness — "if remote caching is on for this project, this is live; if it is off, this is a loaded gun whose trigger is one dashboard toggle." Either way the declaration is missing and the fix is the same. ⛔ Holding a correct, self-contained fix hostage to a severity question would be the error, not the caution.

    Premise re-measured at origin/main

    @objectstack/docs#build entry in turbo.json:  0  (absent — premise holds)
    CONTROL, sibling $TURBO_ROOT$/content inputs that DO exist:
      turbo.json:35  "$TURBO_ROOT$/content/docs/references/**"
      turbo.json:42  "$TURBO_ROOT$/content/docs/api/error-catalog.mdx"
      turbo.json:73  "$TURBO_ROOT$/content/docs/deployment/cli.mdx"
    

    ⇒ The zero is real (the grep finds the idiom four lines away), and the repo already knows how to write this declaration. This is a missing entry, not a new mechanism.


    Generated by Claude Code

  6. claude commented on Aug 23, 2026

    @claude
    Contributor
    {
      "issue": 11264,
      "status": "done",
      "branch": "claude/issue-11264-docs-build-content-inputs",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11418",
      "premise_still_valid": true,
      "head_sha": "6f13167",
      "files_changed": ["turbo.json (+5 -0)"],
      "summary": "Added the missing `@objectstack/docs#build` entry to the root turbo.json, declaring `$TURBO_ROOT$/content/**` alongside `$TURBO_DEFAULT$`. dependsOn and outputs were copied verbatim from the INHERITED generic `build` task rather than from the card's suggestion (which proposed narrowing outputs to `.next/**` + `!.next/cache/**`, dropping `dist/**` and `json-schema/**`) so that the input set is the only thing this PR moves. Sweep for other tasks with the same gap found no second fix warranted; the one near-miss (`@objectstack/docs#typecheck`) was measured as a non-defect, see `sweep`.",
      "probe": {
        "criterion": "appending a line to a file under content/ must MOVE the @objectstack/docs#build hash",
        "method": "append one line to content/docs/index.mdx, re-derive via `pnpm turbo run build --filter=@objectstack/docs --dry=json`, revert; mutation proven on disk each leg (marker grep -c = 1, 3892 -> 3938 bytes), restore proven (marker grep -c = 0, back to 3892); script carries `trap ... EXIT INT TERM`",
        "before_fix": {
          "at": "bb00644 (origin/main)",
          "declared_inputs": 35,
          "inputs_under_content": 0,
          "hash_before_content_edit": "a19d5ea9a91fe215",
          "hash_after_content_edit": "a19d5ea9a91fe215",
          "verdict": "HASH IMMOBILE"
        },
        "after_fix": {
          "at": "6f13167 (final commit, clean tree)",
          "declared_inputs": 477,
          "inputs_under_content": 442,
          "hash_before_content_edit": "7ff31b1218531d71",
          "hash_after_content_edit": "b052c8482715af13",
          "verdict": "HASH MOVED"
        },
        "default_set_preserved": "477 == 35 + 442; 442 == `find content -type f | wc -l`. Had the entry replaced $TURBO_DEFAULT$ instead of adding to it the count would read 442."
      },
      "blast_radius": {
        "method": "dry-run every task graph with turbo.json at origin/main vs. with the change; diff per-task hash + input count + resolvedTaskDefinition",
        "build": "78 tasks compared, 1 differing: @objectstack/docs#build",
        "test": "144 tasks compared, IDENTICAL across all tasks",
        "typecheck": "144 tasks compared, IDENTICAL across all tasks",
        "docs_build_resolved_definition": "exactly one field moves: inputs [] -> [\"../../content/**\"]. SAME: cache, dependsOn [\"^build\"], outputs [\"!.next/cache/**\",\".next/**\",\"dist/**\",\"json-schema/**\"], env, passThroughEnv, persistent, interactive, interruptible, outputLogs"
      },
      "gates": {
        "derived": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack => 0 matched families for turbo.json ('No check family names the given paths in its own source'); --repo assertion held against this checkout's origin",
        "floor_supplement": "diff is build config, so lint.yml/ci.yml step lists were read directly and every gate that PARSES turbo.json was run (4 found by grep for JSON.parse/readFileSync of turbo.json, plus 3 that reference it)",
        "run_at_head_sha": "6f13167 (git status: 0 dirty paths)",
        "results": [
          "check:nul-bytes exit=0 -- 'check-nul-bytes: OK (scanned 6400 text file(s) -- 6400 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).'",
          "check:cross-package-test-inputs exit=0 -- 'OK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'",
          "check:type-check-coverage exit=0 -- 'check-type-check-coverage: OK - 65/78 workspace packages type-checked (plus the root)'",
          "check:examples-live-imports exit=0 -- 'examples/** live-import inventory OK -- 0 invisible (declared), 6 inputs-declared, 72 graph-visible.'",
          "check:test-source-alias exit=0 -- 'check-test-source-alias OK - 72 packages with tests scanned'",
          "check:type-source-resolution exit=0 -- 'check-type-source-resolution OK - 77 packages with a tsconfig.json scanned'",
          "check-dev-prereqs.mjs --self-test exit=0 -- 'check:dev-prereqs --self-test - every verdict reachable, exclusions and freshness coverage pinned (16 cases).'"
        ],
        "one_local_red_attributed_to_baseline": "`pnpm check:dev-prereqs` (full, both halves) exits 1 in a fresh worktree with 'The workspace is not built - 66 of 67 workspace packages declare an entry point under dist/ that is not on disk'. Reproduced IDENTICALLY with turbo.json restored to origin/main (BASELINE_EXIT=1, same verdict line), so it is the unbuilt-worktree precondition, not this change. lint.yml:2525-2526 runs ONLY `--self-test` (green above); the scan half is not a CI gate.",
        "not_run_locally": "the repo-wide `pnpm lint` farm and the remaining ~139 discovered families - CI runs the farm exactly once regardless; PM reviews CI convergence"
      },
      "line_budget": "n/a - diff touches no `skills/**` path. files_changed = turbo.json only (+5 -0), so no published-skill line/token reading is owed.",
      "changeset": "none, by design: @objectstack/docs is `private: true` and turbo.json is repo infrastructure, so this PR releases nothing. `skip-changeset` applied via the additive POST endpoint (returned ['skip-changeset']) and READ BACK after the size labeler settled: ['size/xs','skip-changeset'] - label survived, no fallback PUT needed.",
      "sweep": {
        "question": "the card's last line - does any other task consuming content/ have the same gap?",
        "answer": "no second fix warranted",
        "@objectstack/docs#typecheck": "MEASURED NEGATIVE, not filed. Same shape on paper (35 inputs, 0 under content/) and it does read content/ (fumadocs-mdx regenerates .source/ from it), but its VERDICT cannot depend on content: apps/docs/.source/server.ts is `@ts-nocheck`. Measured rather than reasoned - ran the task with a frontmatter violation already in place (title: 12345 plus a bogus key in content/docs/index.mdx) and it PASSED, TYPECHECK_EXIT=0, executing hash 2f7650656e812054. Declaring content/** there would invalidate its cache on every docs edit while changing no verdict - a cost, not a fix.",
        "gen:schema / gen:skill-refs": "the only other cached non-test turbo tasks; neither reads content/ (grep for content/(docs|blog) in build-schemas.ts and build-skill-references.ts: no hits). gen:docs, which DOES write content/docs/references/, is not a turbo task at all (turbo.json declares 24 tasks; gen:docs is not among them), so it is uncached.",
        "#test tasks": "spec, cli, dogfood, rest, create-objectstack already declare their content/ reads and check:cross-package-test-inputs --verify enforces it - it passes here with 'turbo.json hashes every declared glob'.",
        "scripts/check-*.mjs gates reading content/": "not turbo tasks; lint.yml invokes them directly, so no cache can stale them."
      },
      "rulings_honoured": {
        "zone1_1": "file surface is root turbo.json only; .github/workflows/** untouched; no breach to report",
        "zone1_2": "acceptance probe measured both directions, before and after - see `probe`",
        "zone1_3": "no TURBO_FORCE, no cache-disabling flag anywhere in the diff"
      },
      "zone2_pm_assumptions": {
        "docs#build absent from turbo.json": "CONFIRMED - grep count 0 at bb00644; baseline re-check during the blast-radius run printed 'baseline turbo.json docs#build entries: 0'",
        "$TURBO_ROOT$/content idiom already present": "CONFIRMED, and BROADER than the four instances given: 8 content/ inputs across 5 sibling tasks (turbo.json:35,42,73,74,75,186,197,244). Line 244 (create-objectstack#test) is the exact glob `$TURBO_ROOT$/content/**` this fix uses.",
        "card's suggested entry unvalidated": "CONFIRMED UNSAFE AS WRITTEN and NOT copied. The inherited generic build task resolves to outputs ['!.next/cache/**','.next/**','dist/**','json-schema/**']; the card's suggestion drops dist/** and json-schema/**, which would have been a second behaviour change riding along. Derived entry keeps the inherited dependsOn and outputs verbatim."
      },
      "deviations": [
        "`pnpm install --workspace-concurrency=2` is REJECTED by pnpm 10.31.0: \"ERROR Unknown option: 'workspace-concurrency'\". The flag exists for `pnpm --filter ... run`, not for `install`. Re-ran plain `pnpm install` (exit 0, 4.8s). Worth correcting in the os-dev playbook's Toolchain traps section, which states the flag placement rule without scoping it to run-commands.",
        "No comment was added to the new turbo.json entry despite it looking like a duplicate of the generic build task. scripts/check-type-check-coverage.mjs:3271 does a strict JSON.parse(readFileSync(join(ROOT,'turbo.json'))), so JSONC would red that gate. Checked before editing rather than after."
      ],
      "tests": "No unit test added: the change is a build-graph input declaration with no runtime surface, and its acceptance criterion is the card's hash probe, which is executable and was executed (see `probe`) at the final commit 6f13167 on a clean tree. Gate union run at that same sha - see `gates.results`, all exit 0, each quoting the gate's own printed verdict line (exit codes captured by redirect-then-capture, never after a pipe). Blast radius measured across 366 task instances (78 build + 144 test + 144 typecheck): exactly one differs.",
      "open_questions": [],
      "out_of_scope_findings": []
    }
    

    Generated by Claude Code

  7. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    ACCEPT — PR #11418 · reviewer of record: devx@objectstack (session_015ahemw8RcTgqtxrj15PEZx)

    Checklist passed. +5/-0 on one file, and every claim below re-checked against GitHub or origin/main.

    The acceptance probe was executed, both directions, with a control

    before fix (bb00644) after fix (6f13167)
    declared inputs 35 477
    inputs under content/ 0 442
    hash before content edit a19d5ea9a91fe215 7ff31b1218531d71
    hash after content edit a19d5ea9a91fe215 b052c8482715af13
    verdict immobile moved

    ⭐ And the control that makes the "after" number mean something: 477 == 35 + 442, with 442 == find content -type f | wc -l. Had the entry replaced $TURBO_DEFAULT$ instead of adding to it, the count would read 442 — so this reading distinguishes "added content inputs" from "clobbered the package's own inputs", which a bare "the hash moved" could not.

    ⭐ It refused the card's suggested entry — and was right to

    The card proposed outputs: [".next/**", "!.next/cache/**"]. The inherited generic build task actually resolves to ['!.next/cache/**', '.next/**', 'dist/**', 'json-schema/**'] — so copying the suggestion would have silently dropped dist/** and json-schema/**, riding a second, unrelated behaviour change along with the input fix.

    I verified the shipped diff keeps all four:

    "@objectstack/docs#build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "json-schema/**", ".next/**", "!.next/cache/**"],
      "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/content/**"]
    }

    My Zone 2 note flagged the suggestion as unvalidated and asked it to derive the entry from the generic task instead. It did, and found the concrete hazard I only suspected.

    Blast radius measured, not argued

    366 task instances dry-run with turbo.json at origin/main vs. with the change: build 78 tasks → 1 differs; test 144 → identical; typecheck 144 → identical. And the one differing task's resolved definition moves exactly one field (inputs), with cache, dependsOn, outputs, env, passThroughEnv, persistent, interactive, interruptible, outputLogs all unchanged. For a build-graph edit — where the risk is precisely "what else did I silently re-key?" — that is the right instrument.

    ⭐ The sweep answered with a measured NEGATIVE

    The card's last line asked whether other content/-consuming tasks share the gap. @objectstack/docs#typecheck is the same shape on paper (35 inputs, 0 under content/) and genuinely does read content/. It was not filed, because its verdict cannot depend on content: apps/docs/.source/server.ts is @ts-nocheck. Proven rather than reasoned — a frontmatter violation was planted (title: 12345 plus a bogus key) and the task still passed, exit 0.

    ⇒ Declaring content/** there would invalidate its cache on every docs edit while changing no verdict — "a cost, not a fix." Correctly identifying a near-miss as a non-defect is worth as much as finding a real one, and is much easier to get wrong in the generous direction.

    The one local red was correctly attributed, not waved away

    pnpm check:dev-prereqs exits 1 in a fresh worktree ("workspace is not built"). Attributed to baseline the only way that counts: reproduced identically with turbo.json restored to origin/main, same verdict line. And it checked what CI actually runs — lint.yml:2525-2526 invokes only --self-test, which is green.

    ⭐ It applied the #11404 lesson from earlier this round

    dispatch-gates derived 0 matched families for turbo.json. Rather than reading that as "no gates owed", it treated the derived list as a floor — read lint.yml/ci.yml step lists directly and ran every gate that parses turbo.json (4 found by grepping for JSON.parse/readFileSync of it, plus 3 that reference it). That is exactly the discipline #11404 exists to make unnecessary, applied while it is still necessary.

    Also checked before editing rather than after: no JSONC comment was added to the new entry, because check-type-check-coverage.mjs:3271 does a strict JSON.parse of turbo.json and a comment would red it.

    Deviation worth carrying beyond this card

    pnpm install --workspace-concurrency=2 is rejected by pnpm 10.31.0: ERROR Unknown option: 'workspace-concurrency'. The flag exists for pnpm --filter … run, not for install.

    ⚠️ Second independent hit this round — the #10907 dev reported the same thing in its own deviations. Two devs, two cards, same wasted cycle. Filing separately for the skills lane.

    skip-changeset is correct (@objectstack/docs is private: true, turbo.json is repo infrastructure) and was read back after the size labeler settled.

    Landing

    turbo.json is not governed surface ⇒ normal queue path. CI at review time: 11 success · 10 skipped · 4 pending · 0 red. Holding for all-green, then ready → arm → poll the queue ref. pm:dispatched stays until MERGED.


    Generated by Claude Code

  8. os-steve commented on Aug 23, 2026

    @os-steve
    Collaborator

    Queued. PR #11418 entered the merge queue at 16:49:09Z (refs/heads/gh-readonly-queue/main/pr-11418-…), behind pr-11382.

    Final gate read at head 6f13167: 26 checks · 0 red · 0 pending. Path fork clean — turbo.json is not governed surface. Ready-flip then arm; queue membership confirmed by polling the ref (present on the second poll, ~18s).

    pm:dispatched stays until MERGED. Subscribed to the PR's activity.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions