Skip to content

feat(cli,runtime): artifact-pinned boot — OS_ARTIFACT_URL with an SRI-style fragment pin (#8368) - #8526

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8368-artifact-pinned-boot
Aug 13, 2026
Merged

feat(cli,runtime): artifact-pinned boot — OS_ARTIFACT_URL with an SRI-style fragment pin (#8368)#8526
os-zhuang merged 2 commits into
mainfrom
claude/issue-8368-artifact-pinned-boot

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8368

Boot a stack from a published artifact by reference: a fixed runtime image
plus one environment variable is a running app, and upgrading the app is an env
change plus a restart rather than an image rebuild.

OS_ARTIFACT_URL=https://cdn.example.com/hotcrm-2.2.2.json # fetched at boot
OS_ARTIFACT_URL=file:///srv/app/objectstack.json # read directly
OS_ARTIFACT_URL='https://cdn.example.com/hotcrm-2.2.2.json#sha256=64-hex'# content-verified

One variable, not two

The issue title says OS_ARTIFACT_URL (+ OS_ARTIFACT_SHA256); the issue
body says there is deliberately no separate OS_ARTIFACT_SHA256. The body
was implemented. Only one environment variable exists, and the integrity pin is
SRI-style inside the URL fragment.

The fragment placement is mechanical, not stylistic: a fragment is client-side
by standard and is never sent to the server, so the pin travels with the
reference — one value to copy, one value to rotate — without changing a byte of
what the artifact host sees. A second variable would make "URL updated, hash
not" a reachable state. This shape makes it unspellable.

Nothing found while reading contradicts the body, so this was not escalated.

Where the code landed

The dispatch keyed serialization on packages/cli/src/commands/serve.ts, and
that file is touched — but the mechanism itself landed in packages/runtime:

filerole
packages/runtime/src/artifact-reference.tsnew — parse, fetch/read, verify, cache, protocol-check, redact
packages/cli/src/utils/artifact-boot-migration.tsnew — the boot migration policy (acceptance #5)
packages/cli/src/commands/serve.tsresolves the reference before any artifact lookup; registers the migration gate
packages/cli/src/commands/start.tssteps out of the way when the reference is set
packages/cli/src/utils/schema-migrate.tsexports the existing SQL-driver lookup instead of a second copy

Fetching and booting an artifact is open-framework mechanism, so it belongs in
the framework rather than in the CLI. No entitlement check was added — walled
tenancy postures stay entitled through @objectstack/organizations regardless
of how the artifact arrives.

Precedence, and why it beats OS_ARTIFACT_PATH

--artifact > OS_ARTIFACT_URL > OS_ARTIFACT_PATH > dist/objectstack.json

Beating OS_ARTIFACT_PATH is not a nicety, it is what makes acceptance #1
reachable at all: the official runtime image sets
ENV OS_ARTIFACT_PATH=/srv/app/objectstack.json, so on a container carrying no
app that variable is always set and always points at a file that does not
exist. Without this precedence the feature would refuse with "the artifact
named by OS_ARTIFACT_PATH does not exist" exactly where it was designed to be
used. The e2e suite sets that variable on every child specifically to hold this.

The reference also wins over an objectstack.config.ts in the working
directory: naming a published artifact is an explicit instruction, and a
deployed app must not depend on which directory the process happens to stand in.

Acceptance criteria

#CriterionWhere it is measured
1boots with no project checkout, both schemesartifact-pinned-boot.e2e.test.ts — real os serve child in a provably empty dir
2no pin means no verification; failure is loud, no cache fallbackartifact-reference.test.ts — refusal asserted with a usable cache planted
3pin verified; mismatch names expected and actual; cache fallback only on a hash matchartifact-reference.test.ts + e2e
4engines.protocol validated, refuses loudlyartifact-reference.test.ts + e2e
5safe migrations run; destructive refuses the bootartifact-boot-migration.test.ts
6credentials never echoedboth suites, each with a positive control

A correction to the card on #4

engines.protocol was already enforced for artifact boots — AppPlugin.init
runs assertProtocolCompat on every bundle it loads. So "an incompatible
artifact fails the boot" was true before this change, and a test asserting only
a non-zero exit would have proved nothing about this PR. What is new is where
the refusal happens: at reference resolution, before the artifact boot is even
announced and before any datasource connects, naming the reference rather than
the package. The e2e assertion is written to require that placement, and
Ablation C below confirms it discriminates.

An unplanned finding: fetch refuses userinfo

fetch will not construct a request from a URL carrying userinfo — undici
raises "Request cannot be constructed from a URL that includes credentials"
before a packet leaves. A https://user:token@host/app.json reference was
therefore unusable. The credential is now moved into an Authorization: Basic
header, which is also where it belongs: a credential in the request line lands
in the artifact host's access log, and this is the last point that can decide
otherwise. Found by the secrets test failing on its positive control.

Secrets discipline (#6)

Two structural defences, because a rule that depends on every future call site
remembering to redact is a rule that leaks:

  1. Nothing downstream ever sees the URL. Remote bytes are materialised to a
    local file under home/artifacts and the boot continues against that path,
    so the reference reaches neither the banner, nor MetadataPlugin, nor the
    metadata service's artifact-source record, nor any log line. This is also
    what makes the pin mean anything: the bytes that were hashed are the bytes
    that boot, and the artifact is fetched exactly once (asserted).
  2. Every message is scrubbed, including messages originating inside fetch
    — which routinely carry the whole URL, and which is the classic leak: a
    refusal that helpfully prints the pre-signed URL. Known credential tokens are
    removed and then any surviving absolute URL is replaced, so a leak needs a
    new carrier rather than just a new call site.

Every absence assertion is paired with a positive control asserting the same
captured text contains the host and path of the same URL — otherwise
not.toContain(secret) would pass against an empty capture or a code path that
never ran.

Reverse verification

Predictions were written down before any ablation ran
(three ablations, each re-applied to a committed tree and reverted from the
commit afterwards).

Ablation A — delete the integrity check on the remote path

PredictedMeasured
unit failures33 — mismatch naming both digests; cache-poisoning; the integrity-refusal secrets case
refuses a file:// mismatch toostays greenstayed green (separate check, untouched)
e2e failures22
which line the secrets e2e fails onthe positive controlwrong — it failed one line earlier, at expect(code).not.toBe(0)

The last row is a genuine miss, recorded rather than smoothed over. With
verification removed the mismatched artifact simply boots successfully, so
the failure surfaces at the exit code before the positive control is reached.
The directional claim (the secret assertions are not what carries this test)
held; the specific prediction did not.

Ablation B — give the unpinned path a cache fallback

Predicted exactly one red: fails the boot loudly ... EVEN THOUGH a usable cached copy exists. Measured: exactly one red.

A first, crude version of this ablation produced three reds — two of them
artifacts of the sloppy ablation rather than evidence about the tests. It was
redone faithfully (a URL-keyed "remember the last good copy" fallback, the
plausible wrong implementation), and the test's plant was improved to write
both places such an implementation would look.

Second-order measurement, to check the claim that this test is sharp rather
than merely red: with Ablation B still in place, the planted cache was deleted.
The test went green again — confirming it discriminates because of the
plant, not because the network was cut. That is the difference between this
test and one that cannot fail.

Ablation C — make the protocol refusal a no-op

PredictedMeasured
unit failures22
e2e expect(code).not.toBe(0)stays green (AppPlugin still refuses)stayed green
e2e red linetoContain('Cannot boot from OS_ARTIFACT_URL')exactly that line

This is the ablation that matters most, and it confirms the e2e protocol test
measures this PR's contribution rather than riding on the pre-existing handshake.

Verification

  • pnpm --filter @objectstack/runtime test152 files, 2357 tests, all pass
  • pnpm --filter @objectstack/cli test119 files, 1294 tests, all pass
  • pnpm --filter @objectstack/runtime typecheck, pnpm --filter @objectstack/cli typecheck — clean
  • Gates on a full built closure (70 build tasks): check:type-check-debt
    (re-measure OK, 33 entries, none above its recorded number),
    check:type-check-coverage, check:nul-bytes, check:error-code-casing,
    check:startup-registry-verdict, check:cross-package-test-inputs,
    check:query-options-erasure, check:changeset-gate-self-tests,
    check:doc-anchors, check:doc-authoring, check:adr-anchors,
    check:empty-changeset, check:objectui-changeset, check:release-notes,
    check:published-files — all pass. ESLint clean on every changed file.

check:type-check-debt did go red mid-way: the new runtime test file added 29
raw tsc errors to TEST_DEBT. The ledger was not raised. All 29 came from
one pattern — .catch((e) => e as ArtifactReferenceError) types as
Error | Resolved — replaced by a refusalOf() helper that also makes the
tests stricter (a call that wrongly succeeds now fails as itself). Re-measured
back to exactly the recorded 227.

Notes

  • Error codes are SCREAMING_SNAKE per ADR-0112 but deliberately not
    registered in ERROR_CODE_LEDGER: every refusal here happens before the HTTP
    server binds, so none can reach a response envelope, and registering one would
    create exactly the unemittable row the ledger calls a defect.
  • The migration gate runs on kernel:ready (Phase 3) — after every plugin's
    start(), so schema sync has run, and before Phase 4 opens the socket. "Refuse
    to boot" is literal: the port never binds. It is scoped to the artifact-pinned
    path; every other boot keeps the standing production policy untouched.
  • needs_confirm drift is applied alongside safe, matching
    os migrate apply's own boundary. Re-deriving that boundary here would be a
    second opinion about which changes are dangerous.
  • Out of scope and untouched, per the card: OS_PACKAGE_REF / registry
    resolution / signature enforcement / entitlements; multi-tenant fleet and
    hostname routing; cloud-side adoption (objectstack-ai/cloud#1292 — this card
    is the upstream and does not wait on it).
  • finding: serve.ts's cloud-connected marketplace arm can silently replace a host config's own marketplace plugins #8357 is not addressed here; serve.ts's cloud-connected marketplace arm was
    read and deliberately left alone so that card stays independently reviewable.
  • No new out-of-scope defects were found worth filing.

Generated by Claude Code

Boot a stack from a published artifact by reference — one env var, with the
optional integrity pin SRI-style inside the URL fragment (no companion
OS_ARTIFACT_SHA256). Fetched (https) or read directly (file), verified,
protocol-checked and materialised locally before the boot continues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
…d boot (#8368)
Unit coverage for reference parsing, the fragment pin, integrity verification,
the pinned-only cache fallback, the protocol handshake and secrets redaction;
a fake-driver suite for the boot migration policy; and an end-to-end suite that
boots `os serve` in an empty directory with no project checkout.
Two assertions are written against a specific way of being wrong: the unpinned
fetch-failure case plants a usable cache first (so "no cache-fallback logic" can
actually fail), and every credential-absence assertion carries a positive
control proving the captured text is the text that would have carried it.
`refusalOf()` replaces `.catch((e) => e as ArtifactReferenceError)` so a call
that wrongly succeeds fails as itself rather than on a missing property — which
also keeps the runtime TEST_DEBT ledger at its recorded 227 instead of raising it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 5:27pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime.

32 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via packages/cli)
  • content/docs/api/client-sdk.mdx(via @objectstack/cli, packages/runtime)
  • content/docs/api/data-flow.mdx(via @objectstack/cli)
  • content/docs/api/environment-routing.mdx(via @objectstack/cli)
  • content/docs/api/error-catalog.mdx(via @objectstack/cli)
  • content/docs/api/index.mdx(via @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via packages/cli, @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/backup-restore.mdx(via @objectstack/cli)
  • content/docs/deployment/cli.mdx(via @objectstack/cli)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx(via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/cli, @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/index.mdx(via packages/cli)
  • content/docs/permissions/authentication.mdx(via @objectstack/cli, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/runtime)
  • content/docs/plugins/index.mdx(via @objectstack/cli)
  • content/docs/plugins/packages.mdx(via @objectstack/cli, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx(via @objectstack/cli)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/cli, @objectstack/runtime)
  • content/docs/releases/v16.mdx(via @objectstack/cli)
  • content/docs/releases/v17.mdx(via @objectstack/cli, @objectstack/runtime)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 19:28
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit 116c0d9Aug 13, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8368-artifact-pinned-boot branch August 13, 2026 19:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Artifact-pinned boot: OS_ARTIFACT_URL (+ OS_ARTIFACT_SHA256) — boot a stack from a published artifact by reference

2 participants

@os-zhuang@claude