Skip to content

fix(cli): os dev -a and os start --artifact serve the named artifact beside a cwd objectstack.config.ts — one artifact precedence for start, dev and the serve child - #21549

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21501-artifact-flag-precedence
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21501-artifact-flag-precedence

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21501

Clause-②: no

What changed

The ruling on the card is one precedence, written once: explicit flag > env (OS_ARTIFACT_URL / OS_ARTIFACT_PATH) > dist/objectstack.json > a cwd objectstack.config.ts. Triage amended it in 5966519064: a cwd config joins the boot when the resolved artifact is its own compiled output, because a host config's compiled file cannot carry its code plugins. This PR builds that order in five parts.

  • The one resolver lives in packages/cli/src/utils/artifact-precedence.ts.
    • resolveArtifactBootSource() holds the ladder: --artifact > OS_ARTIFACT_URL > OS_ARTIFACT_PATH > CWD/dist/objectstack.json > HOME/dist/objectstack.json (os start only) > unresolved, which is the cwd config. os start and os dev both resolve through it.
    • cwdConfigJoinsBoot() answers the last rung, as amended: the config joins only when the resolved artifact is its own compiled output. The supervisors' Config: row and the serve child both read this one predicate.
    • start.ts drops its private resolveArtifactSource and its inline OS_ARTIFACT_URL handling. dev.ts drops its inline ladder, which had no OS_ARTIFACT_URL rung.
    • A structural pin refuses any read of OS_ARTIFACT_PATH or OS_ARTIFACT_URL in start.ts or dev.ts.
  • The serve child boots the supervisor's answer beside a config (serve.ts).
    • Before, the child read OS_INTERNAL_ARTIFACT_PATH only when the cwd held no config. With a config present, the config boot ran instead, and its standalone stack re-derived the artifact from the environment.
    • Now the config joins only when the answer is the config's own compiled output. Any other answer boots alone, exactly as from a directory with no config. When the config joins, the config boot is handed the answer explicitly.
  • A config's compiled output, recognised where the command compiled it (artifact-precedence.ts, internal-artifact-channel.ts, dev.ts, serve.ts).
    • The conventional CONFIGDIR/dist/objectstack.json still counts. So does the path the supervising command itself compiles the config to.
    • os dev under a local OS_ARTIFACT_PATH compiles the cwd config INTO that path, so it declares that path to the child on a second private variable of the same channel, OS_INTERNAL_CONFIG_OUTPUT_PATH. That variable is set only when declared and is owned by the parent.
    • isConfigCompiledArtifact(path, configPath, compiledTo) recognises either place, so a host config compiled to a named path still composes its plugins.
  • The honest ready banner (serve.ts, utils/format.ts). On a config boot, the child's ready banner names what actually loaded:
    • Artifact: dist/objectstack.json when a non-host config's standalone stack served the app from a compiled bundle. The proof is the stack's own AppPlugin over that bundle, and the path comes from the runtime's own resolveDefaultArtifactPath over the same explicit input;
    • Config: objectstack.config.ts for a host config (its plugins hold code) or a config with no bundle loaded.
    • No ready-banner row names a file the boot did not load. No os start stale-artifact warning is added, per triage.
  • os dev flag over env (internal-artifact-channel.ts). A resolved channel decision removes OS_ARTIFACT_URL from the child env. Without a flag, dev treats a reference the way start does: it hands no channel down, prints a redacted Artifact: ... (OS_ARTIFACT_URL) row, and does not compile, watch or run the staleness check.
  • Docs: in content/docs/deployment/cli.mdx, the os dev options row lists -a's env equivalents as OS_ARTIFACT_URL / OS_ARTIFACT_PATH, as the resolver's ladder says. That is the only docs edit. The PM declares this docs path to domain:devx.

What does not change:

  • A bare os dev, a bare os start in a project, and the documented os start --artifact ./dist/objectstack.json all name the config's own compiled output, so the config still joins on those paths. The showcase is a host config, and it still boots itself.
  • A direct os serve is untouched, since no supervisor channel is involved.
  • packages/runtime's own fallback ladder is not edited.

Measured at the public door

Two artifacts differ in one served value, the label of object fx_widget. The label was read back through GET /api/v1/meta/object/fx_widget, booted through the built entry bin/run.js.

boot before (base 550f4cc2fd) after
leg 1: os dev -a ALPHA, beside a config whose dist/ holds BRAVO Widget BRAVO Widget ALPHA
leg 2: os start --artifact ALPHA, same directory Widget BRAVO Widget ALPHA
leg 2: os start --artifact ALPHA, config but no dist/ Widget CONFIG Widget ALPHA
leg 2 control: os start --artifact ALPHA, no config Widget ALPHA Widget ALPHA
os start --artifact ALPHA, beside a host config (plugin instance in plugins) Widget CONFIG Widget ALPHA
OS_ARTIFACT_URL=file://.../BRAVO.json os dev -a ALPHA Widget BRAVO Widget ALPHA
OS_ARTIFACT_PATH=ALPHA os start --artifact ./dist/objectstack.json (dist = BRAVO) Widget ALPHA Widget BRAVO
os start --artifact ./dist/objectstack.json beside its config (documented path) Widget BRAVO Widget BRAVO
bare os start beside a host config, dist/ = BRAVO: ready-banner row Config: objectstack.config.ts (served CONFIG; the supervisor row said Artifact: dist/objectstack.json) Config: objectstack.config.ts, served CONFIG
bare os start beside a non-host config, dist/ = BRAVO: ready-banner row Config: objectstack.config.ts (served BRAVO) Artifact: dist/objectstack.json, served BRAVO
OS_ARTIFACT_PATH=build/named.json os dev beside a host config: plugin roster marker absent at the round-1 head (ablations D and E below) marker present, Config: objectstack.config.ts

Artifact: banner. Before the fix, the supervisor printed Artifact: from its own resolution before spawning. The child then printed Loading objectstack.config.ts... and a ready-banner Config: row, so one screen named two sources. After the fix:

  • the child boots exactly the supervisor's answer;
  • beside a config it does not load, the child says so;
  • the supervisors print Config: only when cwdConfigJoinsBoot says the config takes part;
  • the child's ready banner names the config or the bundle it actually loaded.

Env leg. OS_ARTIFACT_URL already outranked a cwd config. Two flag-over-env violations were in scope and are now fixed and pinned: the os dev reference case and the twin-plus-OS_ARTIFACT_PATH case. OS_ARTIFACT_PATH beside a config now boots that artifact alone under os start. Under os dev, it is the path dev compiles the config to, so the config joins.

Raise rule. No deploy was measured serving a different stack this way. The shipped runtime image and the scaffolded Dockerfile copy only the artifact into /srv/app, with no config beside it.

Pins

  • packages/cli/test/artifact-flag-precedence.integration.test.ts (integration tier) runs 10 cases over the source entry. All boots happen in beforeAll.
    • leg 1, leg 2 with and without dist/, and the leg 2 no-config control;
    • a host config beside a named artifact. This is read through the boot's plugin roster, because the source entry runs NODE_ENV=development, where the dev metadata door serves the channel's artifact even with the config loaded;
    • a bare os start beside a host config with a differing dist/: the ready banner says Config:, and the roster marker is present (its positive control);
    • a bare os start beside a non-host config: the ready banner says Artifact: dist/objectstack.json;
    • os dev under OS_ARTIFACT_PATH=build/named.json beside a host config: the config is compiled there and still composes its plugins;
    • dev -a under OS_ARTIFACT_URL;
    • the documented path under an exported OS_ARTIFACT_PATH.
  • packages/cli/src/commands/artifact-child-env.pin.test.ts:
    • the ladder over resolveArtifactBootSource;
    • cwdConfigJoinsBoot and isConfigCompiledArtifact, including the command's own compile path;
    • the channel's ownership of both private variables, and its removal of an outranked OS_ARTIFACT_URL;
    • the structural no-second-ladder pin.
  • The serve-banner-config-row.test.ts and format.config-artifact-row.test.ts unit pins cover the new bundle row.

Reverse verification

All mutations ran through scripts/ablation-replace.mjs in WRAP mode on the committed tree, with literal anchors. The subject runs from src/ through bin/run-dev.js, so there is no dist/ leg.

ablation predicted red observed
A: the whole serve fix leg 1, leg 2, leg 2 no-dist, host config, documented path exactly those 5 red
A1: configJoins ignores the channel host config 1 red
A2: config boot not handed the answer documented path under OS_ARTIFACT_PATH 1 red
B: channel keeps OS_ARTIFACT_URL flag over env, channel unit pin 2 red
C1: ready banner never names the bundle bare non-host banner (integration) and the banner-row unit pin 2 red, 14 green
C2: banner names what the supervisor resolved, not what loaded bare host-config banner 1 red, 9 green
D: channel never hands down the compile path named-path host case and the channel unit pin 2 red, 41 green
E: predicate ignores the command's compile path named-path host case and the predicate unit pin 2 red, 41 green

Every run ended with ok restored: blob == HEAD and an empty git diff HEAD. Ablations A to B ran at round 1 (f5c0a890b7), and C1 to E at round 2.

Verification (final commit f634c5bf09)

  • pnpm --filter @objectstack/cli exec vitest run --project unit: 251 files, 3690 tests passed.
  • pnpm --filter @objectstack/cli typecheck passed, including check:test-typecheck: OK with the debt ledger unchanged.
  • Integration: artifact-flag-precedence.integration.test.ts and dev-no-watch.pin.test.ts, 18 of 18 passed; artifact-child-env.pin.test.ts, 33 of 33.
  • dispatch-gates --commands --repo objectstack-ai/objectstack derived 97 families at f634c5bf09. All 97 ran with exit 0, after a full turbo run build. --ran reconciliation: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN, with the zero derived from recorded exit codes.
  • pnpm lint: a proven narrowing, not the repo-wide run.
    • The population comes from eslint.config.mjs's own globs, and none of the 10 changed .ts files is ignored.
    • eslint --no-inline-config --format json over the 10 files reports 10 results, 0 errors, 0 warnings.
    • The config has no parserOptions.project and reads only two untouched baseline JSONs, so untouched files' verdicts cannot move.
  • Main was merged in at c4528fad62 (10454b3afa). Main has not moved under serve.ts, dev.ts or start.ts since.

Acceptance notes

  • The supervisor's pre-boot Artifact: row on the config-joins path is unchanged. On a bare os start beside a host config, os start still prints 📦 Artifact: dist/objectstack.json before it spawns, and that file is not what boots (non-dev). The child's ready banner after the boot now says Config: objectstack.config.ts, which is the row triage named for this. The supervisor cannot tell a host config from a non-host one without loading the config. Dropping or rewording its row would change every bare os dev / os start banner, so this round does not do it, and the seat decides.

  • os dev's handling of OS_ARTIFACT_PATH is pre-existing. It compiles into that path when the file is missing (or under --compile), and its watch loop rebuilds there. This PR only makes the child recognise that file as the config's output.

  • The round-1 merge commit f5c0a890b7 carries no trailer pair. Every other commit carries the model-free pair.


Generated by Claude Code

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 20 documentable anchor(s).

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: os dev (command, 32 pages), os serve (command, 30 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 899ff6d63045e5ca752894718f21e27161a1cc25 — the merge of head f634c5bf09327f47cfb2f260c9262d741e84bea7 into base 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 899ff6d63045e5ca752894718f21e27161a1cc25 && git checkout 899ff6d63045e5ca752894718f21e27161a1cc25
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c f634c5bf09327f47cfb2f260c9262d741e84bea7 && git checkout -B drift-repro 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c && git merge --no-ff f634c5bf09327f47cfb2f260c9262d741e84bea7

node scripts/docs-audit/affected-docs.mjs --json 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5dbcee8a6d4f3a3feac3ab0a64e5555a1f45cf7c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants