Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ principle are wrong by definition — the principle wins until an ADR supersedes
it. In particular: **we don't bundle the app's code, and we don't guess** — the
framework never bundles/transforms your code, and assembles the deploy artifact
only by documented, deterministic steps (no filename/depth guessing, no tree
laundering)
([ADR-0005](docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md)).
laundering; symlinks are preserved as links only when their resolved target
stays inside the bundle, and never dereferenced)
([ADR-0005](docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md),
amended by
[ADR-0047](docs/design/90-decisions/ADR-0047-compute-assembly-preserves-safe-runtime-topology.md)).

For design work, also check:

Expand Down
8 changes: 7 additions & 1 deletion architecture.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
"layer": "foundation",
"plane": "shared"
},
{
"glob": "packages/0-framework/2-authoring/bundle-paths/src/**",
"domain": "framework",
"layer": "authoring",
"plane": "control"
},
{
"glob": "packages/0-framework/1-core/core/src/*.ts",
"domain": "framework",
Expand Down Expand Up @@ -908,7 +914,7 @@
{
"from": "packages/1-prisma-cloud/1-extensions/target/src/control/**",
"to": "packages/9-public/composer-prisma-cloud/src/exports/local-target.ts",
"reason": "ADR-0041's lazy local-target reference (operator directive; naming, operator 2026-07-23): control/extension.ts's `localTarget` field is a dynamic import of its own published local-target subpath by bare specifier, so no local-target implementation code is bundled into, or loaded by, any deploy path. The specifier resolves at a CONSUMING app's runtime, never as a real build-time dependency between these two packages verified: dist/control.mjs keeps it as a genuine external dynamic import, never inlined (target's invariant 7 test)."
"reason": "ADR-0041's lazy local-target reference (operator directive; naming, operator 2026-07-23): control/extension.ts's `localTarget` field is a dynamic import of its own published local-target subpath by bare specifier, so no local-target implementation code is bundled into, or loaded by, any deploy path. The specifier resolves at a CONSUMING app's runtime, never as a real build-time dependency between these two packages \u2014 verified: dist/control.mjs keeps it as a genuine external dynamic import, never inlined (target's invariant 7 test)."
}
],
"layerOrder": {
Expand Down
9 changes: 7 additions & 2 deletions docs/design/01-principles/architectural-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,13 @@ copied in exactly as the Next docs prescribe). What it must **never** do is
*guess* or *launder*: no filename guessing (the wrapper's name is dictated), no
monorepo-depth inference (the app's location in a standalone tree is *found* by
locating `server.js`, not computed), no baking absolute paths into artifacts, and
a symlinked `node_modules` is a hard error, never dereferenced. See
[ADR-0005](../90-decisions/ADR-0005-users-build-the-framework-assembles.md);
a symlink is **never** dereferenced. A symlink survives packaging as a symlink
only after assembly resolves its real target and proves that target stays inside
the bundle; a link that escapes the bundle or dangles is a hard error naming the
link. Runtime files enter the bundle only by tracing the entry the author
declared — never by discovering one. See
[ADR-0005](../90-decisions/ADR-0005-users-build-the-framework-assembles.md) and
[ADR-0047](../90-decisions/ADR-0047-compute-assembly-preserves-safe-runtime-topology.md);
every guessing/laundering violation has produced a real deploy failure. Do not
relitigate.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ADR-0005: Users build their app; the framework assembles deploy artifacts from built output

Superseded in part by [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md): assembly may trace the declared entry's runtime files and preserve symlinks whose targets remain inside the final bundle; it still never dereferences a link or guesses an entry.

## Decision

The framework never initiates or configures a user's build. The contract is:
Expand Down Expand Up @@ -30,7 +32,11 @@ disciplines bound it — each was violated in the first real out-of-repo deploy:
symlinked (non-hoisted) `node_modules` is a **hard error** at package time,
never dereferenced — the user's to fix (a hoisted linker: npm, or pnpm/bun
`node-linker=hoisted`), because that same non-flat install also crashes a Next
standalone server at boot.
standalone server at boot. *(Amended by
[ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md): a
symlink whose resolved target stays inside the assembled bundle is preserved
as a symlink; only links that escape the bundle or dangle are hard errors. A
link is still never dereferenced.)*
- **Code boundary, not runtime.** A plain `node()` service relies on the Compute
runtime's `bun` auto-install for the dynamic requires its bundler missed (e.g.
`pg/lib/*`); a `nextjs()` artifact *disables* auto-install (its `sharp` /
Expand Down Expand Up @@ -105,6 +111,9 @@ declared location fails loudly — an error naming the resolved path and saying
output, never *how* to produce it.
- Any monorepo layout deploys — the app's deep location is found, not assumed;
a non-hoisted (symlinked) `node_modules` fails fast with an actionable error.
*(Amended by [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md):
it fails only when a link's resolved target lands outside the bundle or is
missing.)*
- Deploy never writes into `node_modules` or the user's build output; staging is
deploy-owned, keyed by graph address.
- The wrapper bundle resolves the user's own dependencies (the service module
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR-0047: Compute assembly preserves safe runtime topology and proves routing

## Decision

Composer's Compute path turns declared built output into a self-contained artifact without rewriting application code:

```text
declared entry ──trace imports──▶ staged runtime files
safe symlink ────────────────▶ archived as a symlink
missing traced target ─────────▶ staged from the declared trace root
escaping link ────────────────▶ hard error
promoted URL ──route probe───▶ deploy succeeds
```

The Node directory adapter traces the explicitly declared entry's static runtime file graph and stages those files beside the directory the author named. It does not choose an entry, run a build, or bundle the application.

The Compute archive preserves a symlink as a tar symlink only after resolving its real target and proving that target remains inside the assembled bundle. Long targets use a POSIX PAX `linkpath` rather than flattening the package tree. Long entry paths ride a PAX `path` record the same way, with a `PaxEntries/<digest>` placeholder left in the legacy USTAR field; extraction is therefore correct only for a consumer that honours PAX records for both the entry path and the link target. It never dereferences the link. Next.js assembly handles one framework-output gap first: if pnpm's standalone tree contains an in-root traced link but omits its virtual-store target, Composer stages the exact corresponding target from Next's declared `outputFileTracingRoot`. A target unavailable there remains dangling and fails, as do links that escape the bundle.

The generated bootstrap may install a narrowly scoped compatibility shim when Compute's JavaScript runtime differs from the Node behavior a framework relies on. Such a shim must be feature-gated to that runtime and must run before the application entry is imported.

Compute also supplies `HOST=0.0.0.0` when the author did not configure a host. Framework servers must listen on Compute's network interface rather than a loopback-only default; an explicit author value remains authoritative. The default is applied wherever `ComputeService.run()` executes, so local development and integration-test harnesses bind to all interfaces too, not only the deployed Compute runtime.

## Reasoning

"Users build; Composer assembles" is a boundary between owning a build and manufacturing a deployment artifact. It does not require Composer to ignore the runtime topology recorded by a build. Astro's Node adapter, for example, emits server files that deliberately retain bare package imports. Copying only `dist/` preserves the bytes but not the runnable program. Tracing from the author-declared entry follows package metadata and import edges deterministically; it is file assembly, not a second application build.

The same distinction applies to symlinks. Dereferencing a package-manager link can silently pull arbitrary deploy-machine files into an artifact, which remains forbidden. Preserving the link itself retains the build's topology. Resolving the target only for validation proves that the archived link cannot escape the artifact, including through a chain of links, without copying the target through the link. The pnpm/Next repair is narrower: its destination is the missing in-artifact target, its source is the same relative path in Next's trace root, and the source's real path must remain inside that root.

Frameworks also exercise details of the Compute runtime that a plain HTTP server may not. A compatibility shim belongs in Composer's generated bootstrap because it is part of the hosting envelope, not the user's framework build. The shim is deliberately narrow: the current URL custom-inspect setter restores Node-compatible assignment semantics for Bun without patching SvelteKit output or changing unrelated globals.

## Consequences

- Node directory artifacts can carry runtime packages that a framework intentionally leaves external.
- Safe package-manager and framework symlinks remain links in both cloud and local artifacts, including peer-context targets longer than USTAR's fixed field; escaping or unresolved dangling links fail before upload.
- Runtime compatibility code is isolated in the generated bootstrap and covered by framework deployment tests.
- Framework servers receive a listen-all host default without overriding an author-configured host.
- These mechanisms are compatibility ownership, not permanent duplication. When the upstream Alchemy Compute provider supplies an equivalent archive, build staging, or runtime bootstrap guarantee, Composer deletes the corresponding local mechanism rather than keeping two implementations.

## Alternatives considered

**Require every build directory to be flat and fully self-contained.** Rejected: standard framework outputs do not all have that shape, and forcing every application to maintain post-build copy scripts moves hosting assembly into userland.

**Dereference symlinks during packaging.** Rejected: it changes the build topology and can package files outside the declared artifact boundary.

**Bundle the application entry again.** Rejected: that crosses the user-build boundary and creates a second framework compatibility surface. Static file tracing preserves the application's emitted code.

## Related

- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — users own builds; Composer owns deterministic artifact assembly.
- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Alchemy is the provisioning engine behind deploy.
- [Architectural principles](../01-principles/architectural-principles.md) — Composer does not bundle application code or guess build output.
3 changes: 2 additions & 1 deletion docs/design/90-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl

- [ADR-0003](ADR-0003-deploy-derives-everything-from-the-root-node.md) — `prisma-composer deploy` derives everything from the root node; there is no deploy config file.
- [ADR-0004](ADR-0004-paths-resolve-relative-to-the-authoring-file.md) — Paths resolve relative to the file that writes them; the build adapter carries the authoring module.
- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — Users build the app's code; the framework assembles the artifact by documented, deterministic steps (validate, wrap, each app-type's documented deploy step — e.g. Next's static/public copy). No guessing (arithmetic/depth-inference/discovery), no laundering (symlink = hard error); read the build tool's own manifest (Next's `relativeAppDir`), don't walk or compute.
- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — Users build the app's code; the framework assembles the artifact by documented, deterministic steps (validate, wrap, each app-type's documented deploy step — e.g. Next's static/public copy). No guessing (arithmetic/depth-inference/discovery), no laundering (symlink = hard error); read the build tool's own manifest (Next's `relativeAppDir`), don't walk or compute. *(Superseded in part by ADR-0047: a symlink whose resolved target stays inside the bundle is preserved as a link; the no-guessing and no-dereferencing rules stand.)*
- [ADR-0006](ADR-0006-every-node-is-named.md) — Every node is named; the root's name names the application.
- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Deploy drives Alchemy through a generated, inspectable stack file.
- [ADR-0008](ADR-0008-wrapper-inlines-everything-except-runtime-builtins.md) — The boot wrapper inlines everything except runtime built-ins.
Expand Down Expand Up @@ -68,3 +68,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl
- [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md) — Errors are structural envelopes with dotted `NAMESPACE.SUBCODE` codes (the shared prisma/prisma foundation, duplicated pending extraction): structured at origin with why/fix splits, no catch-all codes, bugs carry no code (exit 1 + report hint), recognition is structural (`CliStructuredError.is()`), operation results ride the shared `Result` `ok` discriminator, expected failures exit 2 — with the alchemy child-status passthrough as the documented exception.
- [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) — Deploy state lives behind the platform state API (the Management API implements Alchemy's stock `HttpStateApi` wire contract per Branch; composer's state layer is Alchemy's stock HTTP client), and deploys hold a server-side per-`(stack, stage)` lease (TTL 60s, heartbeated, released on exit; contention fails fast naming the holder; state operations without a live lease fail 409). Supersedes ADR-0010 (lock → lease) and the storage half of ADR-0034 (Branch scoping and lifetime stand; the visible per-stage database is gone); closes ADR-0012 as obsolete. No migration: legacy stages are refused until destroyed or deleted.
- [ADR-0046](ADR-0046-the-orm-facade-is-a-peer-dependency.md) — `@prisma/composer-prisma-cloud` takes the Prisma Next postgres facade (`@prisma/orm-postgres`) as a **peer** dependency at one exact version, not a regular dependency: Composer registers an extension pack against the application's copy of the target, and two copies of a shell in one tree means two codec/operation registries and two class identities — a value from one is rejected by the other, silently. As a peer, that combination fails at install instead. Every `@prisma/orm-*` spec in the workspace is one exact version and all name the same one (`scripts/lint-orm-pins.mjs`). `@prisma/orm-toolchain`, which Composer drives rather than extends, stays a regular dependency. Replaces ADR-0022's consequence bullet on how the ORM is installed.
- [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md) — Compute assembly traces runtime files from the author-declared Node entry without rebundling app code, preserves only symlinks whose resolved targets remain inside the staged bundle, and installs narrowly runtime-gated bootstrap compatibility when a framework needs Node semantics. Each local mechanism is removed once the upstream Alchemy Compute provider owns the equivalent guarantee. Supersedes ADR-0005's blanket ban on symlinks and its assumption that directory output is already self-contained.
19 changes: 13 additions & 6 deletions docs/guides/building-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,13 +540,20 @@ finds its siblings exactly where the build left them — resolve them against
`import.meta.url`, not the working directory.

Nothing is guessed: you name the directory and the entry, and that is what
ships. Two things to know:

- The tree must contain no symlinks — the platform's packager rejects them, so
assembly fails early and names the link rather than shipping a broken
artifact. Have your build emit real files.
ships. Three things to know:

- Symlinks are kept as symlinks, never followed and copied. A link whose target
resolves inside the built output ships as-is. A link that points outside it,
or at something that isn't there, fails the deploy with an error naming the
link, rather than shipping a broken artifact or packaging files from your
machine.
- The entry's runtime imports ship too. Deploy traces the file you named and
stages the packages it imports beside `dir`, so framework output that keeps
bare imports (Astro's Node adapter, for example) boots without you copying
`node_modules` into the build.
- `entry` must be a file inside `dir`. Pointing it outside with `../` is an
error, not an escape hatch — only `dir` is copied.
error, not an escape hatch — only `dir` is copied verbatim; everything else
arrives through the trace.

Without `dir` you get the single-file form above, unchanged.

Expand Down
22 changes: 22 additions & 0 deletions packages/0-framework/2-authoring/bundle-paths/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@internal/bundle-paths",
"version": "0.6.0",
"private": true,
"type": "module",
"description": "The path-containment predicate and bundle symlink validation shared by assembly and packaging (ADR-0047's boundary, defined once).",
"exports": {
".": "./dist/index.mjs",
"./package.json": "./package.json"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsdown",
"clean": "rm -rf dist",
"test": "bun test"
},
"devDependencies": {
"typescript": "^6.0.3",
"tsdown": "^0.22.7",
"@internal/tsdown-config": "workspace:0.6.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { assertBundleSymlinksStayInside, isWithin } from './bundle-paths.ts';

describe('isWithin', () => {
test('the root itself and descendants are within; siblings and parents are not', () => {
expect(isWithin('/a/b', '/a/b')).toBe(true);
expect(isWithin('/a/b', '/a/b/c/d')).toBe(true);
expect(isWithin('/a/b', '/a')).toBe(false);
expect(isWithin('/a/b', '/a/c')).toBe(false);
expect(isWithin('/a/b', '/a/b-evil')).toBe(false);
expect(isWithin('/a/b', '/a/b/../c')).toBe(false);
});
});

describe('assertBundleSymlinksStayInside', () => {
const scratch = () => fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-paths-'));

test('accepts a bundle whose links resolve inside it', async () => {
const bundle = path.join(scratch(), 'bundle');
fs.mkdirSync(path.join(bundle, 'real'), { recursive: true });
fs.symlinkSync(path.join('.', 'real'), path.join(bundle, 'link'));

await assertBundleSymlinksStayInside(bundle);
});

test('rejects a dangling link', async () => {
const bundle = path.join(scratch(), 'bundle');
fs.mkdirSync(bundle, { recursive: true });
fs.symlinkSync('./missing', path.join(bundle, 'link'));

await expect(assertBundleSymlinksStayInside(bundle)).rejects.toThrow('dangling symlink');
});

test('rejects a link whose target escapes the bundle', async () => {
const parent = scratch();
const bundle = path.join(parent, 'bundle');
fs.mkdirSync(path.join(parent, 'outside'), { recursive: true });
fs.mkdirSync(bundle, { recursive: true });
fs.symlinkSync('../outside', path.join(bundle, 'link'));

await expect(assertBundleSymlinksStayInside(bundle)).rejects.toThrow('escapes the bundle');
});
});
Loading
Loading