fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631

Queued
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars
Queued

fix(cli): let the dev artifact door own stack-declared security metadata on a host config#14631
os-trump wants to merge 8 commits into
mainfrom
claude/issue-14397-os-dev-host-config-registrars

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14397

os dev over a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the way createStandaloneStack already does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.

The two readings, first — this card's actual deliverable

Both were taken by booting the real os dev supervisor (packages/cli/bin/run.js dev, which compiles, writes OS_INTERNAL_ARTIFACT_PATH, and spawns os serve --dev), never a hand-composed kernel. Full transcript on #14397.

1. Does os dev compile a HOST config into dist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.

Case: examples/app-showcase, a host config by isHostConfig's own rule (its plugins[] holds new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(...), new ConnectorRestPlugin(...), new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level. apps/objectos does not exist in this repo — serve.ts names it only in a comment — so the in-repo host config above is the live case. dist/ was deleted first:

 → Compiling objectstack.config.ts → dist/objectstack.json...
Artifact: .../examples/app-showcase/dist/objectstack.json (696.0 KB)
INFO [MetadataPlugin] Loading metadata from local artifact file {"path":".../app-showcase/dist/objectstack.json"}
INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
[MetadataPlugin] artifact file watcher attached .../app-showcase/dist/objectstack.json
[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events

dev.ts never asks what shape the config is: needsCompile is !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, and resolveDefaultArtifactPath returns an explicit path verbatim, so the door's guard resolves on every os dev boot.

The second writer is in the same boot, 620 ms after the door:

2026-09-02T10:20:21.215Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246}
2026-09-02T10:20:21.835Z INFO Registered stack-declared security metadata {"appId":"com.example.showcase","count":23}

2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.

The card's answer was an inference from kernel.use order ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemory is a Map.set, so the last writer wins, and the wrap registers last.

Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in plugins[], all four collections at top level, no AppPlugin and no MetadataPlugin of its own) plus one observer plugin that samples metadata.list(kind) on a timer and changes none of the three predicates under test. The compiled twin's label values were rewritten on disk so the two copies are distinguishable by value:

samplesource of the surviving copylabel_packageVersion
cold boot, t+2s and t+8sthe config MODULE (the wrap)authored labelsabsent on all four
after one artifact reload, no restartthe compiled ARTIFACT (the door)artifact labels1.0.0 on all four
2026-09-02T10:28:15.232Z INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":5}
2026-09-02T10:28:15.453Z INFO [MetadataPlugin] artifact auto-reloaded (file watcher) {"path":".../dist/objectstack.json"}

Four further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.

One correction to the card's description. The card says the module copy carries a sharing rule's condition as a bare string. On a CONFIG boot it does not: defineStack() defaults to strict: true and runs ObjectStackDefinitionSchema.safeParse itself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.

The fix

serve.ts knows both facts in one function, so the decision is made once, next to the wrap:

  • the dev artifact door is resolved and CONSTRUCTED before the wrap, into a single devArtifactDoor value;
  • the wrap is built as new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});
  • only the kernel.use stays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.

Registrar for the four collections, by boot shape:

bootdoor composed?registrar beforeregistrar after
os dev, host config, artifact compiledyesapp-plugin (+ the door, two writers)artifact-door (door only)
os dev, host config, artifact missing or door fails to composenoapp-pluginapp-plugin (unchanged)
os serve / os migrate, host confignoapp-pluginapp-plugin (unchanged)
artifact boot / createStandaloneStackyesartifact-doorartifact-door (untouched)

Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured, os serve over the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot. hasMetadataPlugin is read one block earlier than before and the answer is identical: plugins is not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.

The door is gated on EXISTENCE, not resolution.resolveDefaultArtifactPath hands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file at start() with an info line — so gating on resolution alone would let os dev --artifact ./typo.json over a host config boot green with NO registrar for the four kinds. The door is therefore composed only when fs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:

 ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at PATH
Stack-declared security metadata stays with the app wrap, its registrar on every
boot without a door.

The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.

The kernel.use catch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered: Kernel.use only validates the plugin and registers it by name, and init / start run later in bootstrap, so for an already-constructed MetadataPlugin on a still-idle kernel that catch does not fire.

Fences honoured

createStandaloneStack and packages/metadata/** are untouched — this card is not #12892's, and that PR's fence holds. plugin-detection.ts (isHostConfig / shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.

Verification

Head 0337430d4c. The PR carries one fix commit, three origin/main merges, and three patch-round commits answering the contract review.

Pin, red-first by ablation.packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gate serve.ts from 3dfd83a0e4 and runs the pin, then restores from HEAD and runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with a trap ... EXIT INT TERM restoring an absolute path:

HEAD blob: 86b5052c35fd148c43aa494f9f12ffd095fd9298
BASE blob: c85c46a26e12e8157a46c35f09457a456eed3d4b
on-disk blob after mutation: c85c46a26e12e8157a46c35f09457a456eed3d4b
removed-text count (fs.existsSync(hmrArtifactPath)): 0
restored-text count (The app wrap deferred): 1
ABLATED RUN EXIT=1
× the door is composed only when its artifact EXISTS, not merely resolves
× the `kernel.use` catch does not claim a consequence it cannot have
Tests 2 failed | 4 passed (6)
on-disk blob after restore: 86b5052c35fd148c43aa494f9f12ffd095fd9298
git diff HEAD -- serve.ts: EMPTY (restore proven)
restored-text count (fs.existsSync(hmrArtifactPath)): 1
RESTORED RUN EXIT=0
Tests 6 passed (6)

Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's @objectstack/runtime import failed to resolve and BOTH legs reported Test Files 1 failed (1) / no tests. The numbers above are from the tree after pnpm --filter '@objectstack/cli^...' build.

Targeted, under the shared verify lock (VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheck EXIT=0; the pin EXIT=0, Test Files 1 passed (1) / Tests 6 passed (6). The package's tsconfig.json has include: ["src"] and no exclude, so that typecheck does cover the test file.

Whole-repo lint:pnpm lint (eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.

Gate union. At 3dfd83a0e4, dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and --ran reconciled: ✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED. Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words — check-test-completeness.mjs exit 3 (PREREQUISITE NOT MET, it grades a saved turbo run test log; the gate itself says this is not a red), check-half-states.mjs exit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), and check:type-check-debt exit 124 (killed at a 540 s bound while --re-measure re-ran tsc per ledger entry; CI's lint.yml measures it).

That union still stands at the patched head, and the reason is checkable: origin/main has not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diff clean). What changed is content inside those paths, so the content-sensitive members were re-run at 0337430d4c: pnpm check:nul-bytes, check-empty-changeset, check-changeset-no-major, check-keyed-text-bounds, check-comment-mask-adoption, check:test-source-alias, check:cross-package-test-inputs — all EXIT=0.

One transient red is worth recording because it reads like a real finding: pnpm check:dual-build-cjs-loads once reported 17 @objectstack/spec "types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a local dist state and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touches packages/spec.

Shared verify lock. Heavily contended across both rounds (repeated exit 99 queue timeouts, holders named in each VERDICT line). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), the check:* gate union and pnpm lintcheck:* gates and eslint are outside the lock's own stated coverage.

Boot measurement rig

The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The examples/app-showcase run used the app unmodified; the content reading used a temp host config whose node_modules was symlinked to the showcase's so @objectstack/* resolved through the workspace.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

os-trumpand others added 4 commits September 2, 2026 10:36
…ata on a host config
`os dev` over a HOST config composed two writers over one stack: the
`new AppPlugin(config)` wrap over the config module (default `'app-plugin'`
registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide
the door once, before the wrap, and declare `securityMetadataRegistrar:
'artifact-door'` on the wrap exactly when that door composes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 20b883918aa787e65299e72b85c2b3396aa2006f.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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.

Coarse fallback — 22 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 20b883918aa787e65299e72b85c2b3396aa2006fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5103405d4063aa015fd6bc41862162f52af08260 — the merge of head 727d705f9299e087de3760e483c3b30620db710b into base 20b883918aa787e65299e72b85c2b3396aa2006f, 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 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f

⚠️ 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 20b883918aa787e65299e72b85c2b3396aa2006f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-trumpand others added 3 commits September 2, 2026 16:06
…disk
Contract review found that gating on resolution alone regressed the very hole
the change removes: `resolveDefaultArtifactPath` returns a named path verbatim
without an existence check, and the door tolerates ENOENT by starting empty, so
`os dev --artifact ./typo.json` over a host config left all four security
collections with no registrar at all. Gate the door on `fs.existsSync` and name
the missing path in the warning; drop the consequence text from the `kernel.use`
catch, which cannot fire for an already-constructed plugin on an idle kernel;
correct the shipped wording — the two copies differ by ADR-0010 provenance and
freshness, not by parsing, since `defineStack()` is strict by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment
The docblock, the changeset and the serve.ts comment already say what was
measured; this one inline comment above the unconditional-shape assertion still
carried the retired wording. The second writer's copy is strict-parsed like the
door's — it lacks the ADR-0010 provenance stamp and never refreshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 17:38
@os-trump
os-trump added this pull request to the merge queueSep 2, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

What happened

This PR was armed at 17:38:58Z on head 727d705f9299e087de3760e483c3b30620db710b with all 33 checks green, entered the merge queue at ~18:3xZ, and was evicted at 19:03Z by queue build 33668324620. It is back to open and main did not move past it.

The failing check, from the triage bot's own extraction — the FILE, not the log tail:

@objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts >
the mirror direction: a reader that is never coming back >
gives up and exits instead of waiting forever
↳ 失败原因: AssertionError: expected 'SIGKILL' to be null

Why this is not being re-queued

Not because it is "flaky". The bot's own discriminator says an AssertionError naming a duration, timestamp or elapsed count is an experiment-validity assertion and re-runnable, while one naming product behaviour is not. This one names a signal — a child that should have exited on its own had to be killed. It does not get the free re-run reading.

The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, test/run-dev-unbuilt-workspace.e2e.test.ts has ejected five independent PRs in 24h#14499, #14593, #14617, #14629 and this one — and the bot notes its 24h ledger was not read to the end, so five is a lower bound. Four of those five do not touch this PR's files at all. A queue rebuild triggered before the underlying test is fixed rebuilds every PR queued behind it, for a failure that will simply recur.

This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure.

The fix is already in flight, and it is this seat's

#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.

⚠️ One thing this seat is NOT claiming: that the failure is unrelated to this PR

It would be convenient to call this a bystander eviction and move on. The honest reading is that it is not established:

  • os dev spawns os serve --dev, and this PR changes packages/cli/src/commands/serve.ts.
  • The test runs on an unbuilt workspace, where no compiled artifact exists — and this PR's whole change is a gate that composes the dev artifact door only when fs.existsSync(hmrArtifactPath). On that branch the door is therefore not composed in exactly the scenario this test drives, and a warning is printed where a plugin used to be constructed.

That is a real behavioural difference on precisely this test's path. Against it: the test was ejecting PRs that never touched serve.ts before this gate existed.

Both can be true, so the seat has asked the one agent already inside that file (the #14648 dev) to answer it directly rather than run a duplicate investigation from here: does the failure reproduce on origin/main without this gate, and does it reproduce more often with it? If the gate makes it worse, that is a finding about this PR and it comes back here as a patch round to this PR's dev — not something absorbed into #14648's scope.

State


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-trump