Skip to content

Wire the Static Web Bundle producer into the authoring build lane - #1252

Open
Koh0920 wants to merge 30 commits into
mainfrom
feat/static-web-lane-wiring
Open

Wire the Static Web Bundle producer into the authoring build lane#1252
Koh0920 wants to merge 30 commits into
mainfrom
feat/static-web-lane-wiring

Conversation

@Koh0920

@Koh0920 Koh0920 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Part 3/3 of the Static/Compute publication platform (contract: ato-api#502, PWA: ato-pwa#278).

  • CapsuleManifestV1 gains optional [outputs.static_web] (root / entry_path / spa_fallback / connect_src), deny_unknown_fields, TOML round-trip, validation shared with the static-web contract; root="." = source root (aligned with the API zod schema).
  • Detector: suggested-lane inference now emits outputs.static_web for high-confidence static repos (root index.html without server entrypoints → root="."; plain vite-build shape → literal build.outDir or dist, refusing unparseable overrides / server frameworks). Authored manifests and overlays untouched; dist/-existence alone never flips static.
  • Build lane: on a claim carrying publication.resolved_publication_lane == "static_web" (inside the existing static-web-bundle-v1 capability), the clean_replay lane extracts the DECLARED output root from the exported guest rootfs, produces the Static Web Bundle (deterministic swm_<base32-sha256> materialization id per Build Config Revision — idempotent re-runs converge on the registry), and drives prepare → upload → verify → complete with the authoring lease. Producer failure fails the build attempt with structured codes (static_web_output_missing / _outside_workspace / _entry_missing / _bundle_invalid / _secret_detected / _prepare_failed / _upload_failed / _finalize_failed). Secret scan = canary scan + PEM/AKIA text scan.
  • Claims now advertise supported_features: ["static-web-bundle-v1"].

Tests: capsule manifest 355 green (4 new), snapshot-builder static_web 11 green, authoring_runtime 31 green; cargo check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NV566T4RuF8QPR37mHEgh

Koh0920 and others added 30 commits August 4, 2026 14:07
- Add additive optional [outputs] / [outputs.static_web] to CapsuleManifestV1.
- Validate root (non-empty relative, no absolute/./../backslash), entry_path
  (normalized relative), and connect_src (public https/wss origins, ASCII
  sorted, unique) — path existence is a Build Attempt fact, not a parse fact.
- Absent [outputs] keeps snapshot-only behavior; a dist/ directory never
  selects the static web lane by itself.
…ring

PR 4 — connect the pure producer (PR 1) and the API registry (PR 3) through
the builder claim, with an explicit no-op for un-declared output:

- authoring_runtime: accept the Build Config Revision plan fields the API
  claim already sends (build_config_revision_id, plan_digest,
  effective_build_plan, …) — the claim previously carried them but the
  deny_unknown_fields struct refused them. Add AuthoringWork::static_web_output_plan
  to surface the declared [outputs.static_web] section.
- static_web_output: StaticWebOutputPlan::from_effective_build_plan_json —
  a declared section is authoritative (unsafe root etc. fails the build);
  absence is a snapshot-only no-op. A dist/ directory never selects the lane.
- static_web_transport: prepare → batch upload-authorizations → parallel PUT
  (fresh URL per retry, URL never logged/persisted, digest-derived keys
  asserted) → verify → complete, mirroring source_archive_upload's security
  model. Builder holds no storage credential.
- static_web_emit: the only place the three outcomes are decided (skip /
  STATIC_WEB_OUTPUT_MISSING / STATIC_WEB_BUNDLE_FAILED) and the bundle is
  uploaded.
- 15 new tests: transport no-reuse/redaction/conflict, claim plan parsing,
  emit no-op/missing-root/upload.

The Firecracker/Docker image-export call site (feeding the built image tree
to emit_static_web) is the remaining rollout seam; the plan parsing, transport,
and decision logic are fully covered here.
…SP origins

Review fixes on the pure producer + contracts:

- extract_static_web_output: an intermediate symlink (e.g. image_root/srv ->
  outside) could escape the image root because only the final component was
  checked. Every component of image_output_root is now verified as a real
  directory, the canonical source must stay strictly beneath the canonical
  image root, and copy_tree_no_links re-canonicalizes each entry (TOCTOU
  guard). Tests: intermediate/absolute/relative-.. symlink escapes and an
  in-tree symlink all refused.
- validate_relative_path: ANY literal `%` is now refused, matching the
  worker's multi-decode behavior (percent-encoded separators/traversal can no
  longer pass the producer and fail the consumer). New invalid fixture cases.
- manifest validation: one blob digest may back many paths but every
  occurrence must agree on size (the R2 object has one size); receipt
  validate_for_manifest no longer hides a digest-size disagreement via
  or_insert.
- produce_static_web_bundle: files are opened once, read bounded at
  MAX_FILE_SIZE+1 (no unbounded builder memory), and re-stated through the
  SAME descriptor after the read (fstat inode+size on unix) so the manifest
  size, receipt size, and hashed bytes are one fact.
- frame_ancestors: the fixed v1 trust set is now the REAL embedding origins:
  https://ato.run, https://app.ato.run, https://staging.store.ato.run,
  https://stg-app.ato.run. staging.ato.run has no DNS record (ato-api staging
  config), so it is removed; a frame-ancestor that can never frame is a false
  promise in the policy. All golden JCS fixtures, expected digests, and p/s
  host labels regenerated in lockstep.
The API only emits the build-plan extension fields (effective_build_plan,
plan_digest, authored TOML, revision binding) to a builder that advertised
'static-web-bundle-v1'; a legacy builder keeps receiving the byte-compatible
claim it can parse (deny_unknown_fields would hard-fail otherwise). Rollout is
builder-first, API-second.

- ClaimRequest now carries supported_features; claim() advertises
  static-web-bundle-v1, claim_with_features() is the test seam.
- Tests: the legacy claim shape (no plan fields) still parses, and the
  capability is advertised on the wire.
…uild (B1)

The vertical slice's production path is now wired end to end:

- build_v1's clean_replay exports the assembled guest filesystem to
  <work_root>/guest-rootfs (existing v1 seam). After the rootfs persists, the
  builder calls static_web_output_plan() and — when the saved Build Config
  Revision declared [outputs.static_web] — runs emit_static_web_if_declared
  against the EXPORTED TREE with the real HTTP transport (prepare → batch
  upload → verify → complete). Absent declaration = complete no-op; a DECLARED
  output that fails = build failure (never a silent snapshot fallback).
- rootfs_pack_script gains an optional export_to that retains a tar copy of
  the exported tree before cleanup (used by future lanes; legacy builds pass
  None and stay byte-identical).
- Test: the call-site shape (guest-rootfs/app/dist tree + declared plan in the
  work JSON + revision id/plan digest) emits + uploads + completes.

This is the seam the real Docker/Firecracker build exercises; the rollout E2E
(Hextris: build -> dist -> bundle -> upload -> s-* URL) completes the
acceptance from here.
…l site

Blocker 1 + Major 1 from re-review:

- ProducedBuild now carries exported_guest_rootfs, set by the v1 lane to the
  ACTUAL tree the producer exported before packing (`<work_root>/guest-rootfs`).
  The clean_replay static-web call site consumes that threaded path instead of
  reconstructing a magic `<jobdir>/v1-work/guest-rootfs` literal — a declared
  output on a lane that produced no exported tree now fails closed with a clear
  error instead of silently finding nothing.
- Removed the dead `export_to` param added to rootfs_pack_script in the prior
  round: it was never wired to any caller (the v1 lane exports natively as a
  required producer step), so it only suggested a mechanism that did not exist.
  rootfs_builder + docker_import pack scripts are back to byte-identical
  pre-round output.
- extract_static_web_output component walk now accumulates the path IN ORDER
  (image_root/a -> image_root/a/a -> ...), so duplicate component names like
  a/a/dist can no longer skip a symlinked later component (the previous
  take_while stopped at the first match). New test pins the duplicate-name
  escape case.
- manifest_v1: outputs.static_web.root, entry_path, source paths, and asset
  paths now reject '%' — the producer's contract validate_relative_path
  already refused it, so a manifest accepted by the authoring surface could
  previously fail at build time. API plan validation matches (Major 3).
- static_web_transport: a 412 Precondition Failed from the create-only blob
  PUT means the digest key ALREADY EXISTS (a crashed earlier run may have
  landed the bytes before verify). The uploader now treats 412 as success and
  proceeds to VERIFY instead of burning the retry budget (Major 2). Test:
  a transport that always 412s converges through verify to complete.
…unt/unknown/duplicate skew) (review round 3)
- manifest v1: optional [outputs.static_web] (root/entry_path/spa_fallback/
  connect_src) matching the wizard API's staticWebOutputSchema constraints,
  omitted from serialization when absent so existing manifests round-trip
  byte-identically; validation reuses the static-web delivery contract's
  path/origin validators
- detector: infer_static_web_outputs emits the declaration only for
  high-confidence static repos (root index.html with no server entrypoint;
  the plain vite build/preview production shape with a readable outDir),
  fails closed everywhere else; run + outputs coexist
- clean-build lane: when the claim's Build Config Revision resolved to
  static_web, extract the DECLARED output root from the exported guest
  rootfs, produce the Static Web Bundle, scan it, and register it via the
  idempotent prepare -> upload -> verify -> complete registry flow; any
  producer failure fails the build attempt with the contract's structured
  static_web_* code
- claim: advertise the static-web-bundle-v1 capability and parse the
  plan-extension + publication claim fields

Claude-Session: https://claude.ai/code/session_013NV566T4RuF8QPR37mHEgh
Use effective_build_plan.static_web_output.materialization_id verbatim when
it is registry-shaped, so the registry row and the effective build plan name
the same materialization; keep the local bcrev-keyed derivation as the
fallback for plans that carry none.

Claude-Session: https://claude.ai/code/session_013NV566T4RuF8QPR37mHEgh
…lder

The staging builder binary was already built from feat/static-web-publication-pr2
(unmerged): a battle-tested static web producer — lease-fenced transport, bundle
emit, declared build-step execution, asset media-type coverage — that this branch
had independently re-implemented against main.

pr2's machinery is the production path and is kept whole. What survives from this
branch is the Publication Lane contract layered on top:

- AuthoringWork carries the server-resolved publication block (tolerant parse;
  absent or unknown means snapshot_compute) plus is_static_web_lane(). The LANE
  is server-owned: a declared outputs section alone never selects it. pr2's
  static_web_output_plan() continues to supply the plan itself.
- The detector's outputs inference and the structured static_web_* failure codes
  ride on pr2's producer paths.
- The manifest keeps pr2's Option<OutputsV1> shape; this branch's call sites were
  adapted rather than the type changed.

cargo check clean; static_web 30, capsule manifest 355, authoring_runtime 36,
capsule authoring_intent 8 — all green.
The reconciliation merge took the shipped branch's manifest validator
wholesale, which dropped this branch's acceptance of the whole-string
root = "." — the form the detector emits for dependency-free static repos
(a root index.html with no build). The API's zod schema accepts it, so the
two sides disagreed and every plain-static build failed at detect with
"outputs.static_web.root is invalid" (caught on staging by the S02-A
fixture, not by unit tests).

"." as a path SEGMENT ("./dist", "dist/.", "a/../b") stays rejected. The
new test pins both halves so a future merge cannot silently drop it again.
The #443 precedence rule — a package.json always wins over a root
index.html — is right for bundler-served apps, but it also swallowed every
static site that uses npm purely for tooling. Those repos declare no start
or dev script, so package-managed inference dead-ends on "requires
scripts.start or scripts.dev" and the app can never be published.

Measured on the real publication backlog: gridgarden, flexboxfroggy and
clumsy-bird ship an empty scripts object, and jspaint declares only
electron packaging and release scripts. All four are static sites; all four
failed detection.

The rule now yields only for the exact decidable combination "root
index.html AND no serving script at all" (start / dev / serve / preview).
Every app #443 was written for still declares one of those, so it keeps the
package-managed path; a malformed or unreadable package.json fails closed to
package-managed.
Two failures made `root = "."` — the dependency-free static case the
detector emits for a plain index.html repo — impossible to publish. Both
were found on staging with 2048: "STATIC_WEB_BUNDLE_FAILED: unsupported
static web media type: .gitignore".

Repository housekeeping is now excluded rather than served. Any
dot-prefixed path segment (.gitignore, .github/, .DS_Store) and the
extension-less legal files every repo ships (LICENSE, COPYING, NOTICE,
AUTHORS, CHANGELOG, Makefile) are dropped from the bundle. A hidden entry
has no place on a public edge surface, and the exclusion is deliberately
narrow so an unknown media type on a REAL asset still fails loudly instead
of vanishing from the site.

The media-type table also lacked things ordinary static sites ship: docs
served next to the app (md, csv, xml, pdf) and the audio/video a browser
game needs (mp3, ogg, wav, m4a, flac, mp4, webm) plus glTF models. Every
addition is inert; executable and unknown types keep failing.

Tests: 3 for the housekeeping predicate (dotfiles excluded, extension-less
metadata excluded, real content — including README.md and favicon.ico —
kept); existing static_web suites 33 + 8 green.
Enumerating repository metadata names fell over on the second real
repository: 2048 ships a Rakefile. Web content is addressed by extension
and the edge has no media type for an extension-less file, so treat the
absence of an extension as the signal — LICENSE, Makefile, Rakefile,
Dockerfile, Procfile, CNAME all drop out by the same rule. Assets with a
real but unrecognized extension still fail loudly.
The bundle rule was enumerate-and-fail: any file whose extension had no
media type killed the whole publish. That is right for a build output
directory and wrong for root = ".", where the tree also holds everything
the site was built from. 2048 alone disproved it three times in a row —
.gitignore, then Rakefile, then style/helpers.scss.

The media-type table is now the definition of publishable content: no
entry means no Content-Type, which means the edge could not serve the file
even if it were bundled, so it is excluded rather than fatal. Hidden
entries stay excluded outright. Source maps remain a hard failure — they
ARE servable, which is exactly why publishing one by accident must not be
silent.
Adding markdown, audio, video and PDF to the emitter produced bundles the
v1 manifest validator then rejected outright ("media type is not in the
v1 allowlist: text/markdown"). is_allowed_media_type is a frozen contract
shared with ato-api, ato-edge and ato-usercontent-static, so the emitter
has to stay a subset of it; widening it is a coordinated v2 change.

Consequence worth stating plainly: a static site's audio, video, markdown
and PDF assets are excluded from a v1 bundle rather than published. Games
that need sound cannot be served through the static lane today.
…pping them

A visually rich page (WebGL games — first hit: HexGL) produces PNGs over
the 500KB ack cap at full size; the capture was skipped entirely, and the
Ready-State Seal's REQUIRED post-restore screenshot then failed the whole
seal. Oversized captures are now downscaled in x0.8 steps (floor 640px
wide) and re-encoded until they fit; only a floor-width still-oversized
capture keeps the old skip behavior.
…ead of refusing

Real repositories carry internal links (first hit: jspaint's
lib/tracky-mouse/website/core, a directory link to a sibling that is also
walked via its real path); the extractor refused ANY link and failed the
whole Build Attempt. Links whose canonical target stays inside the image
root are now materialized as real content; links escaping the root stay
fail-closed, and a link back to a directory on the current recursion stack
(a true cycle) fails closed instead of recursing forever. The extracted
tree still never contains a link.
…output

Dependency provisioning installs node_modules INTO the image, so a
root='.' static output swept it up — thousands of package files plus
tool-generated dangling links (node_modules/.vite) that failed
canonicalization and the whole Build Attempt. node_modules is now a
system exclusion of the static output walk (same spirit as the source
filter's .git), and a dangling link is skipped with a log line — it
materializes nothing and can leak nothing. Cycles and escaping links
stay fail-closed.
…he build

With root='.' the walked tree is the repository itself, and repos ship
committed .map files (jspaint: lib/98.css/98.custom-build.css.map). A
source map is simply not publishable output — skip it like every other
non-web file rather than failing the whole Build Attempt.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant