Skip to content

reader program 6/4 — @objectstack/plugin-dev: the i18n auto-detect never fires for a multi-package app #15232

Description

@hotlong

Part of #14122 · blocks #14512 · split out of #15210 (the by-shape sweep record — read it for the sweep method and the triaged false positives) · acceptance pin is #15004.

Reader half of the program ruled on #14512 (comment 5528589044). The artifact stays additive through this card.

The site

file:line Reads What an option-B artifact does to it
packages/plugins/plugin-dev/src/dev-plugin.ts:526 options.stack.translations the I18nServicePlugin auto-detect never fires, so a multi-package app boots os dev with the in-memory i18n fallback instead of the file-based service

Reached through the documented construction new DevPlugin({ stack: config }) (packages/plugins/plugin-dev/src/index.ts:36).

What the failure looks like

Not a crash and not an empty screen — the app boots and serves. Translations resolve against the in-memory fallback instead of the file-based service, so a developer sees keys or stale strings where the app declared real copy, on the os dev path specifically. It reads as "the translations are wrong" rather than "a collection went missing", which is what makes it worth a card rather than a footnote.

The pin, already on main

#15004 merged. Read all three before writing implementation:

  • packages/cli/test/fixtures/option-b-collection-zoo.ts
  • packages/cli/test/fixtures/option-b-reader-probe.ts
  • packages/cli/test/option-b-reader-acceptance.pin.test.ts

⚠️ This site has no row in OPTION_B_LOSSES today — the by-shape sweep found it, not the pin. So this card does both halves: add the row that ledgers the loss, then fix the reader and delete it. Do it in that order and say so in the PR — a row that never existed is a row nobody can check you against.

The pin asserts by set equality: adding a row for a loss already fixed goes red just as surely as leaving a row for one that is not. ⛔ Never weaken the pin to pass — do not relax set equality, stop asserting a subsystem, or add rows to silence a red.

Acceptance

Sibling cards: #15005 (@objectstack/runtime), #15006 (@objectstack/cli), #15007 (@objectstack/plugin-security, landed as PR #15226), #15229 (5/4, @objectstack/verify). Emitter half: #14512.

Activity

  1. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    Claim

    Dev seat, session session_01UHvF5hyiZjnCyExFnfQB8m, branch claude/issue-15232-plugin-dev-i18n-package-order (pushed empty as a write-path probe before the first edit).

    Worktree: dedicated, off origin/main 1c6f7b4a2. Re-read this card's comments before starting — it carries none, so no earlier claim with a different branch exists. The card is unassigned on arrival; per the dispatch contract the assignee field is the PM's to write, so this comment plus the branch is the claim marker.

    Read in full first: this card, #15210 (the by-shape sweep record and its triaged false positives), #15004 (the acceptance pin) and its three shipped files, #14512's body and reader enumeration (comment 5523603341), and the landed sibling PR #15226 (#15007, plugin-security) as the shape precedent.

    Scope as I will hold it: @objectstack/plugin-dev plus the #15004 probe row this card has to add and then delete. In that order — the row that ledgers the loss goes in first and is shown RED, then the reader is fixed, then the row comes out; both reds recorded in the PR body. ⛔ The pin is not weakened to pass: set equality stays, no subsystem stops being asserted, no row is added to silence a red. composeStacks, packages/spec/src/stack.zod.ts and what any command emits stay untouched — the artifact stays additive through this card.

    The card's own question — whether the auto-detect should fire when a caller hands new DevPlugin({ stack }) an already-composed multi-package config — is answered with evidence in the report and the PR body, not assumed.


    Generated by Claude Code

  2. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 15232,
      "status": "done",
      "branch": "claude/issue-15232-plugin-dev-i18n-package-order",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15282",
      "premise_still_valid": true,
      "summary": "Both halves, in the card's order. (1) The row that ledgers the loss went in FIRST with the reader still top-level-only, and the pin went RED naming an unledgered subsystem — 'B2 · plugin-dev I18nServicePlugin auto-detect over the caller-supplied stack · translations' — with the other five cases green in that same run, so it is a discrimination and not a broken fixture. (2) The row was ledgered (green at 25 rows). (3) The reader was fixed and the pin went RED again, this time naming the line to delete. (4) The line was deleted (green at 24). OPTION_B_LOSSES ends this branch byte-identical to origin/main: `git diff origin/main -- packages/cli/test/option-b-reader-acceptance.pin.test.ts` is empty. The pin was never weakened — set equality intact, no subsystem dropped, nothing added to silence a red. The fix extracts DevPlugin.init's 3b decision into one exported function, devI18nPluginOptions, which reads the flattened top level FIRST (the caller's original expression preserved verbatim, not re-expressed — #15006's trap) and consults package bodies only where that came back falsy, through resolveArtifactPackageOrder; the `packages` key's absence is checked before the call so D4's second branch never re-reads the top level, and a malformed `packages` raises the ADR-0112 envelope rather than being swallowed. The probe row CALLS that shipped decision rather than re-reading `stack.translations`, so it goes present once the reader is fixed instead of staying red forever. The card arrived unassigned — per the dispatch contract I did not write the assignee field; my claim is comment 5537162417 plus the branch. Dispatch prompt and the seat contract disagreed on that one point (the prompt said to assign myself); the contract file wins, and the disagreement is recorded here rather than resolved silently.",
      "tests": "All at 561ae05da (final commit; origin/main merged in first so the gate derivation had no STALE TREE warning). SUITES: `pnpm --filter @objectstack/plugin-dev test` -> `Test Files 7 passed (7) / Tests 68 passed (68)` (11 new cases); `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/option-b-reader-acceptance.pin.test.ts` -> `Test Files 1 passed (1) / Tests 6 passed (6)`. THE FOUR MEASURED STEPS: row-only -> `1 failed | 5 passed` with the verbatim 'A subsystem lost a collection that the ledger does not carry' text naming the new row; ledgered -> 6 passed; fix -> `1 failed | 5 passed` with 'A ledgered subsystem now SEES its collections under option B ... Delete these lines'; row deleted -> 6 passed. ABLATION (committed first, then mutated): the packages[] limb removed, CONFIRMED ON DISK before the run — limb occurrences 1 -> 0 and injected marker 1 by `grep -c`, plus the blob hash moved off HEAD's (8ecb11ff -> 9d0ccdac); suite went `4 failed | 64 passed`, failing exactly the four packages[]-dependent cases while the baseline, the flattened-first short-circuit, the single-package read-once pin and the negative BOOT control stayed green. Restored with `git checkout HEAD -- ABSOLUTE_PATH` under an `EXIT INT TERM` trap and PROVEN restored: `git diff HEAD` empty, `git status` clean, disk hash back to 8ecb11ff, marker count 0. No rebuild leg was owed and that is checkable rather than assumed: the test imports `./dev-i18n` relatively and `@objectstack/core` is aliased to source in plugin-dev's own vitest.config.ts, so no dist/ sits on that resolution path (the pin's own path DID need one — `pnpm --filter '@objectstack/cli^...' build`, VERDICT command-exit 0, run before every pin measurement). GATES: family derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on the merged tree (10 paths, identical list before and after the merge) — 53 of 57 exit 0, every exit code captured BEFORE any pipe (`cmd > log 2>&1; EXIT=$?`). Green includes check:test-source-alias, check:type-source-resolution, check:type-check-debt, check:cross-package-test-inputs, check:undeclared-dep-imports, check:workspace-manifest-cycles, check:turbo-task-graph, check:nul-bytes, check:published-files, check:engine-double-contract, check:changeset-gate-self-tests. NOT MEASURED, not reported as passed — 4, each with its own unmet prerequisite, all CI-owned: check:dual-build-cjs-loads (exit 3, 11 packages have no dist/), check:i18n and check:i18n-coverage (exit 3, the extract configs' build closure), check:i18n-walk-parity (exit 1, packages/cli/dist/utils/i18n-extract.js absent). TYPECHECK: `pnpm --filter @objectstack/plugin-dev typecheck` exit 0, check:test-typecheck `0 file(s) / 0 error(s)`, and `tsc --listFiles` confirms BOTH new plugin-dev files are really in that program (2 of 2) rather than silently outside it; `pnpm --filter @objectstack/cli check:test-typecheck` -> `3 file(s) / 28 error(s) / 6 pinned signature(s)`, byte-identical to the pre-existing test-typecheck-debt.json, so the fourth paths rule adds ZERO diagnostics. LINT: repo-wide `pnpm lint` (eslint . --no-inline-config) exit 0 — no narrowing claimed. Control-byte self-scan over the diff: no matches. DECLARED NARROWING: the @objectstack/cli suite was not run whole, only the pin — measured, not argued: the new alias is anchored so it re-resolves exactly one specifier, `grep -rln '@objectstack/plugin-dev' packages/cli/src packages/cli/test` returns exactly one file (the probe), and packages/cli/vitest.config.ts carries no vite-tsconfig-paths so the tsconfig paths rule cannot move any existing test's resolution. CI state at report time: draft PR open, checks pending (mergeable_state blocked, expected for a draft).",
      "mcp_calls": "6 — issue_read get 15232, issue_read get_comments 14512 (page 2, perPage 4 — the enumeration comment 5523603341, which the public-payload channel cannot reach: that page preloads only 15 of 51 timeline items and offers no zero-quota pagination), add_issue_comment (claim), create_pull_request, pull_request_read get 15282 (the owed full read-back of the PR body — it survived intact, and the platform rewrote the footer into its session-URL form as expected), add_issue_comment (this report). Everything else came through the zero-quota channels: the card bodies and comments of #15232 / #15210 / #15004 / #14512 via the public-repo payload channel, and the landed sibling PR #15226's diff via `git fetch origin refs/pull/15226/head`. Container REST is session-gated 403 for this seat (`GitHub access is not enabled for this session`, probed repo-scoped before anything else) and `gh` is absent, so the writes went to MCP by necessity — channel switch declared.",
      "open_questions": [
        {
          "question": "A malformed `packages[]` now raises out of DevPlugin.init instead of being ignored. DevPlugin's posture everywhere else is to degrade loudly and keep booting (its child init/start loops turn a failure into a log line, with exactly one deliberate exception, the #5301 organizations refusal). Is the refusal the right posture here, or should the dev boot survive it?",
          "options": [
            "A. Let it throw, as landed. `resolveArtifactPackageOrder` is the platform's one gate as well as its one traversal, and the sibling reader (#15007, plugin-security PR #15226) makes the same choice for the same reason: catching it would resolve a posture out of a package list nothing else accepts. Reachable only when the top level declares no translations, since the flattened answer short-circuits — so no artifact or composed config the platform emits today can reach it.",
            "B. Catch it and skip the i18n registration. Consistent with the rest of DevPlugin, but it re-creates the exact failure class this card fixes: an app whose declared copy is silently not served, this time because its package list was malformed rather than because the reader was short-sighted.",
            "C. Catch it and re-report it through the plugin's own loud-error path. Rejected as written: `reportOptionalLoadFailure` says 'PACKAGE is installed but failed to initialize', which would name @objectstack/service-i18n for a metadata-shape defect — the precise mis-attribution #7926 removed from this file."
          ],
          "recommendation": "A, as landed. Real business need: nobody ships a malformed `packages[]` deliberately, and the developer writing one at `os dev` is exactly who benefits from hearing it at the earliest, cheapest moment. Long-term soundness: one refusal semantics platform-wide, with the gate travelling with the read, beats a per-caller dialect — B would make plugin-dev the one reader that answers questions about artifacts the loader refuses. Making AI-authored metadata apps hard to get wrong: a duplicate package id or an unwrapped entry in an AI-written multi-package config should stop the boot, not become a log line under a running server. Startup scope discipline: not catching is less code and no new concept. Flagged rather than buried because it is the one behaviour change beyond the fix itself, and because DevPlugin's own degradation posture points the other way."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  3. self-assigned this
    on Sep 4, 2026
  4. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 15232,
      "status": "done",
      "branch": "claude/issue-15232-plugin-dev-i18n-package-order",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15282",
      "premise_still_valid": true,
      "summary": "Second round: the contract review's REJECT (Q3 + Q4) and the driver-memory census failure are both closed, in one head (c108823e9). The census line was MIGRATED, not ledgered — the `vi.mock('@objectstack/driver-memory')` copied from this package's sibling harnesses was REDUNDANT, because dev-plugin.ts's one import of it sits inside `if (enabled('driver'))` and both boots pass `services: { driver: false }`; deleting it returns the census to 2 ruled consumers with the ledger untouched and the suite unchanged. On the review: the reachability claim was FALSE and is corrected in the code docblock, the test comment and three places in the PR body — the flattened read short-circuits only when `translations` is non-empty, so every multi-package stack that declares no i18n reaches the gate on every boot. That made the reproduced regression real (a package manifest carrying authoring glob `objects` is refused by ArtifactPackageSchema by design, boots today, and would have stopped booting), so DevPlugin now takes posture B: it catches the refusal, logs its OWN line naming the metadata-shape defect and carrying the envelope verbatim, and boots on the in-memory fallback. ⛔ Not via reportOptionalLoadFailure (the #7926 mis-attribution) and ⛔ never silent; dev-plugin.ts:505's AppPlugin try/catch is untouched, as instructed. The three limbs are now asked cheapest-first, both functions document what they throw including the BARE Error from a dependency cycle, and the guard divergence with the sibling reader is recorded rather than unilaterally aligned. The pin is still untouched: OPTION_B_LOSSES 24 -> 24, set equality intact, `git diff origin/main` on that file empty.",
      "tests": "All at c108823e9. SUITES: `pnpm --filter @objectstack/plugin-dev test` -> `Test Files 7 passed (7) / Tests 72 passed (72)` (14 cases in the new file, all green by name under --reporter=verbose); `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/option-b-reader-acceptance.pin.test.ts` -> `Test Files 1 passed (1) / Tests 6 passed (6)`; `pnpm --filter @objectstack/plugin-dev typecheck` -> exit 0, check:test-typecheck `0 file(s) / 0 error(s)`. THE ANSWER THE COORDINATOR ASKED FOR — case 2(a), 'a composed multi-package stack with NO i18n DOES reach the artifact gate': the gate IS REACHED and the call does NOT throw. Measured on a real `composeStacks([...], { manifest: 'preserve' })` output with no translations at any level, wrapped in a Proxy counting reads of `packages`: reads = 2 (once by this reader's absent-key guard, once inside resolveArtifactPackageOrder), the call answers `undefined`, and `expect(...).not.toThrow()` holds. Two reads is the discriminator rather than 'at least one': under the top-level-only ablation the same case fails with 0 reads, which is exactly the short-circuit the false claim asserted. CENSUS, reproduce-then-green: `node scripts/check-driver-memory-census.mjs` exit 1 at 561ae05da (captured before any pipe) naming line 57 -> exit 0 after deleting the mock, `OK ... 2 ruled consumer(s)`, census back to 12 module bindings in 12 files, ledger file untouched. THREE ABLATIONS at c108823e9, each a single-case discrimination, every mutation confirmed on disk (occurrence counts both directions + blob hash moved off HEAD's) and every restore proven (`git checkout HEAD -- ABSOLUTE_PATH` under an EXIT INT TERM trap; empty `git status`, empty `git diff HEAD`, hashes back to dce6439a / ef5e8981, zero ABLATION markers): remove DevPlugin's catch -> `1 failed | 13 passed`, exactly the 'gate REFUSES still boots' case; translations limb first again -> `1 failed | 13 passed`, exactly the 'already declares its locales' case; reader back to top-level-only -> `7 failed | 7 passed`, including THE FIX, the reachability case, the cycle case and both packages[]-dependent boots. GATES re-run at c108823e9: check:driver-memory-census, check:nul-bytes, check:test-source-alias all exit 0; the 57-command family and repo-wide `pnpm lint` were exit 0 at 561ae05da (53 of 57, with 4 NOT MEASURED for unmet prerequisites, all CI-owned). ⚠️ `check:driver-memory-census` is NOT in the family dispatch-gates derives for this change set — that is why the local run was green and CI saw it first; recorded rather than glossed. CI at report time: run 33862124976/33862125028 on c108823e9 still converging — `Lint & Repo Gates` in_progress, no job with a failure conclusion; Check Changeset, Governed Surface Queue Guard, Type Check · workspace, Validate Package Dependencies, Temporal Conformance and the claim guards all success.",
      "mcp_calls": "12 total across both rounds — round 1: issue_read get 15232, issue_read get_comments 14512, add_issue_comment (claim), create_pull_request, pull_request_read get (body read-back), add_issue_comment (report). Round 2: pull_request_read get_comments (the review, read in full first), pull_request_read get_check_runs x2, update_pull_request (body v3, with `draft: true` passed EXPLICITLY — the tool sends the draft bit whether or not you set it), pull_request_read get (full read-back of the new body: it survived intact, and the PATCH dropped the platform's session-URL footer as expected, so the session URL stays in the prose), add_issue_comment (this report). Everything else stayed on zero-quota channels: #15281's diff for the changeset precedent and #15226's for the shape precedent came through `git fetch origin refs/pull/N/head`, and the card/comment reads through the public-payload channel. Container REST is still session-gated 403 for this seat.",
      "open_questions": [
        {
          "question": "SUPERSEDED, recorded so the trail is readable: round 1 flagged whether a malformed `packages[]` should throw out of DevPlugin.init (A: throw / B: catch and degrade / C: reuse reportOptionalLoadFailure) and recommended A. The contract review falsified A's first premise (the walk IS reached in ordinary use) and reproduced a regression under it; the PM ruled B without escalating, on the ground that this is consistency with shipped behaviour rather than a policy change. B is implemented. The residual question is the one the PM split out: should DevPlugin refuse malformed metadata at all, given `new AppPlugin(...)` twenty lines above degrades the same refusal on the same object?",
          "options": [
            "A. Leave both degrading, as now — the i18n detector is no stricter than the metadata gate beside it, which is the invariant this card had to restore.",
            "B. Make DevPlugin refuse malformed metadata outright, at BOTH sites — a coherent posture, but it changes what boots for projects that boot today, so it is a maintainer call and a card of its own.",
            "C. Keep degrading but make the two lines share one diagnosis shape, so an operator sees one vocabulary for 'your metadata is malformed' rather than two."
          ],
          "recommendation": "A here, and the PM's separate card is the right home for B — I did not touch dev-plugin.ts:505, as instructed. C is worth considering on that card rather than this one: the two lines already agree in substance (both name the defect, both say the boot continues), and unifying them means touching the AppPlugin catch, which is exactly what this card was told to leave alone."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    Delta round — the two remaining PR-body items, plus CI convergence and the ready flip. The substantive report for this card is comment 5539092464; this one supersedes only its CI-state and draft-state lines.

    {
      "issue": 15232,
      "status": "done",
      "branch": "claude/issue-15232-plugin-dev-i18n-package-order",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15282",
      "premise_still_valid": true,
      "summary": "No code changed this round — head is still c108823e9 and the diff is untouched. Two PR-body corrections landed. (1) The pin sentence is now stated against the right baseline: relative to this branch's merge base 460134af8 the ledger file is byte-identical and carries the 24 rows that base carried, while main has since moved to 23 because #15226 landed and deleted its own `B2 · plugin-security ... from-source config` row — measured here, not restated: 23 rows in origin/main, 24 on the branch, that row absent from main. The two do not collide: `git merge-tree --write-tree origin/main` against this head exits 0 with zero CONFLICT lines, result tree d90f3eb94. (2) Review item 6 was already in the body but under a heading that did not name it; it is now `Review item 6 — packages/cli/package.json's published bytes DO change, and no cli changeset is owed`, with the #15281 precedent read from that PR's own diff (it edits packages/cli/tsconfig.test.json, the same probe fixture and the pin, and declares only @objectstack/verify). CI then converged green on c108823e9 and the PR is flipped to ready. ⛔ auto-merge is NOT armed — that is the PM's, as instructed.",
      "tests": "No new local runs: no file changed, so every measurement in comment 5539092464 still describes this head. What is new is CI on c108823e9, and the one job the last report could not close is now closed: `Lint & Repo Gates` — the job that carries `check:driver-memory-census` — read directly by check-run id 100988718340, `status: completed, conclusion: success`. Nothing on the PR carries a failure conclusion: Test Core (roll-up) success, Dogfood Regression Gate success, Dogfood Verify CLI success, Temporal Conformance success, Type Check · workspace / consumer gates / debt ledger success, TypeScript Type Check success, Check Changeset success, Governed Surface Queue Guard success, Check PR Size / Auto Label / claim guards success, with Console Pin Gate and Packed-tarball smoke skipped. Local git readings taken for the body correction: `git show origin/main:...pin.test.ts | grep -c` = 23 ledger rows, the branch = 24, the plugin-security from-source row = 0 occurrences in main; `git diff 460134af8 BRANCH -- packages/cli/test/option-b-reader-acceptance.pin.test.ts` = empty; `git merge-tree` exit 0, zero conflicts.",
      "mcp_calls": "22 cumulative. This round: 6 check-run reads (5 listing pages plus one targeted get_check_run by id, which is the cheap way to answer one job), 2 update_pull_request (body v4 with `draft: true` explicit, then the ready flip with `draft: false` alone), 1 pull_request_read get to read the body back in full, and this comment. The git facts behind the body correction cost nothing: origin fetch plus `git show` / `git diff` / `git merge-tree` in the shared checkout, all read-only.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  6. github-actions commented on Sep 6, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34005012908 · trigger schedule

    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

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions