# Forge Assets v1

Status: repository contract for the Forge Assets platform work. The schema and
migration behavior described here are source contracts; using them in a live
Forge deployment still requires a separate Forge release and verification.
Creating an asset migration does not deploy a site, change DNS, or retire an
existing host.

Implemented and locally tested in this source snapshot: standard Git LFS
Batch/basic transfer, account and repository reachability, strict config
parsing, exact-SHA build hydration, unchanged-public-asset publication, and an
administrator-only adoption API/UI that proposes ordinary-blob conversion and
opens a migration pull request after every selected object is verified. These
changes are not deployed until Forge is separately released and live verified.

Forge Assets combines two layers:

- **Git LFS compatibility** lets ordinary Git clients store and retrieve
  standard Git LFS objects from a Forge Git remote.
- **The Forge Asset Graph** gives builds and agents digest-addressed assets,
  selective hydration, account-scoped authorization, reuse receipts, and
  zero-copy publication where the runtime can consume an asset unchanged.

Git remains authoritative for which logical asset path belongs to an exact
commit. Forge's control plane records which account and repository may reach a
digest. Immutable object storage holds the bytes. Repository-authored files
refer to logical SHA-256 digests; they never contain bucket names, object keys,
credentials, or another provider's physical locator.

## Do I need the `git-lfs` binary?

No, not for Forge builds of already-managed pointers, the Sites adoption flow,
or an agent that calls the standard LFS HTTP endpoints directly. The adoption
flow uses the existing standard Forge LFS HTTP API for missing bytes; it does
not invoke a local clean/smudge filter. API callers may implement Batch/basic
requests directly and do not need the local helper binary.

Install `git-lfs` when a developer wants the familiar local Git LFS workflow:

```bash
git lfs install
git clone https://forge.smol.ai/<owner>/<repository>.git
```

With the helper installed, checkout hydrates pointer files and push uploads
missing objects through the standard Git LFS protocol. Without it, Git still
clones and pushes the small pointer files; the working tree shows pointers
instead of the large bytes. Forge builds are unaffected because hydration is a
server-side responsibility.

| Workflow | Local `git-lfs` required? |
| --- | --- |
| `forgeBuild.ts` migration assistant | No; asset adoption is a separate Sites action |
| Agent using the standard LFS HTTP API directly | No |
| Forge exact-SHA build | No |
| Clone/push pointers with ordinary Git | No |
| Automatically hydrate and clean LFS files in a local checkout | Yes |
| Sites/API asset adoption and migration PR | No |

Forge accepts the standard Git LFS pointer and Batch/basic transfer contracts.
This compatibility boundary is intentionally not a Forge-specific pointer
format.

## Optional `.forge/assets.json`

A repository may add one strict configuration file at its root:
`.forge/assets.json`. The file declares reviewed asset intent; it does not
contain storage locations and it does not grant deployment or DNS authority.

```json
{
  "$schema": "https://forge.smol.ai/spec/assets/v1/schema.json",
  "version": 1,
  "mode": "hybrid",
  "entries": [
    {
      "path": "apps/main/public",
      "kind": "tree"
    },
    {
      "path": "media/keynote-source.mp4",
      "kind": "file"
    }
  ]
}
```

The public schema is
[`packages/cli/schema/assets-v1.json`](../../packages/cli/schema/assets-v1.json).

### Fields

| Field | Contract |
| --- | --- |
| `$schema` | Optional editor hint. When present, it must name the Forge Assets v1 schema. |
| `version` | Required and exactly `1`. |
| `mode` | Optional; defaults to and may only be `"hybrid"` in v1. Git continues to hold source and pointers while Forge manages digest bytes and build hydration. |
| `entries` | Required list of reviewed file or tree declarations. |
| `entries[].path` | A literal, normalized, repository-relative path. Globs, absolute paths, backslashes, NUL bytes, `.` segments, and `..` traversal are invalid. |
| `entries[].kind` | `"file"` names one regular file. `"tree"` recursively names regular files below one directory. |

The file is limited to 64 KiB and 1,000 entries; each path is limited to 1,024
UTF-8 bytes. Paths must be unique and non-overlapping after normalization.
The build path validates the strict source shape and discovers regular LFS
pointer files. Adoption additionally rejects missing paths, symlinks, special
files, and submodules before it writes a migration commit.

The file is optional. Without it, Forge still understands standard Git LFS
pointers, but it does not silently convert ordinary Git blobs or infer that a
directory should become a managed asset tree.

## Smart hydration

Forge resolves each build from its exact Git commit and then chooses the
smallest conservative materialization. For an OpenNext build, Forge can keep a
pointer out of the build sandbox only when all of these conditions hold:

- `.forge/assets.json` explicitly covers the pointer;
- the pointer is below the selected project's `public/**` tree; and
- its case-insensitive extension is in Forge's conservative binary/media
  allowlist.

The deferred extensions in v1 are:

```text
.7z .aac .avif .avi .bmp .bz2 .eot .flac .gif .gz .heic .heif .ico
.jpeg .jpg .jxl .m4a .m4v .mkv .mov .mp3 .mp4 .oga .ogg .opus .otf
.pdf .png .rar .svg .tar .tif .tiff .ttf .wav .webm .webp .woff
.woff2 .xz .zip
```

Everything else is hydrated before the build. This includes text and data
files such as JSON, YAML, CSV, Markdown, and GraphQL; extensionless control
files; unknown extensions; pointers outside the reviewed `public/**` tree; and
all pointers for runtimes without this OpenNext publication path. That default
preserves framework behavior such as prerender imports and `fs.readFile()` of
files below `public/`.

The decision is deterministic: it uses the exact committed path, selected
project root, reviewed configuration, runtime, and extension. An AI model does
not decide build-time hydration, and repository code never receives
object-store credentials.

V1 does not statically infer arbitrary file reads performed by repository build
code. If build code opens or transforms a binary/media file, do not cover that
file with the reviewed `public/**` declaration; it will then hydrate before the
build.

### Batched discovery and publication checks

Hydration work is batched so a repository with thousands of pointers does not
incur one sandbox or control-plane round trip per path:

- Forge scans the exact checkout once into a pointer metadata manifest capped
  at **16 MiB**. Unsafe paths, duplicate paths, symlinks, non-regular files,
  malformed pointers, and overflow fail closed.
- Unique SHA-256 object identities are authorized in repository/account-scoped
  D1 statements of up to **90 digests** each. The v1 scale fixture exercises
  3,088 pointers in 35 authorization statements.
- After OpenNext finishes, all deferred output paths are checked in one bounded
  sandbox scan. Each must still be a contained regular pointer with the exact
  expected SHA-256 identity and logical size.

The output scan is not the final trust check. Immediately before any connected
provider publication call, Forge loads each selected object from immutable
storage, applies the provider's per-file limit, and recomputes both the byte
count and SHA-256 digest. A size or digest mismatch fails before publication.
This keeps the fast path credential-free without trusting stale metadata or
performing a redundant per-object storage `HEAD` during the build.

An unchanged digest can be reused without re-upload inside an account after
repository reachability is established. Cross-account callers must
independently upload or prove the bytes; the current protocol deliberately does
not reveal that another account already stored the digest. Physical storage may
deduplicate after verification, while authorization, logical quota, metadata,
and reachability remain account scoped.

## Adopt existing repository blobs

The high-level adoption flow never rewrites already-pushed commits. It creates
a new, reviewable migration branch and pull request from a frozen source
commit:

1. Inspect the exact branch head without running repository code.
2. Propose literal file and tree entries using the defaults below.
3. Let an administrator include or exclude each literal proposal.
4. Upload and verify every selected object by SHA-256 before changing Git.
5. Atomically commit `.forge/assets.json`, `.gitattributes`, and standard Git
   LFS pointers to the migration branch.
6. Open a pull request with the exact source SHA, thresholds, object counts,
   byte counts, and immutable migration receipt.

The pull request can be closed with no history rewrite. Merging it changes only
future commits descended from that merge. Forge suppresses automatic Deploy
previews for this machine-authored migration pull request. Creating it does not
advance the production branch, deploy a release, attach a domain, alter DNS,
or retire the previous host.

### Suggestion defaults

The assistant's default proposal is deliberately small:

- suggest a project `public/**` tree only when its selected regular files total
  **more than 50 MiB**; and
- elsewhere, suggest individual regular files only when each file is **more
  than 5 MiB**.

Both comparisons are strict `>` comparisons. Exactly 50 MiB and exactly 5 MiB
are not selected by default. The current v1 endpoint reports these fixed policy
values with every inspection; administrators customize the result by selecting
or deselecting proposed entries. Threshold overrides are not part of the
callable v1 request.

These thresholds affect migration suggestions only. They are not build limits,
Git LFS protocol limits, or automatic conversion rules.

## AI-native migration API

The authenticated migration surface is designed so an agent does not need to
clone the entire repository, run a clean/smudge filter, or possess object-store
credentials:

1. request a read-only proposal for an exact repository SHA;
2. upload only objects reported with `uploaded: false` through Forge's standard
   authorized LFS Batch/basic API;
3. reinspect, then submit the reviewed entries and exact object identities;
4. request the protected migration branch and pull request; and
5. read durable per-object and per-migration receipts.

The repository-administrator endpoints are:

```text
GET  /api/repos/:owner/:repo/sites/migration/assets?sha=<40-hex-source-sha>
POST /api/repos/:owner/:repo/sites/migration/assets
```

`GET` is read-only. It returns the primary branch and exact source/head SHA,
policy defaults, existing config, literal entry suggestions, each source blob
SHA, SHA-256 LFS OID, size, upload status, and warnings. Inspection streams
ordinary blobs into SHA-256 with bounded concurrency and does not execute
repository code.

The v1 Sites UI reports which selected objects are missing, but it does not
copy ordinary source blobs into Forge Asset storage. Before Apply can succeed,
a user, agent, or standard LFS client must upload those exact bytes through the
LFS transfer API and inspect again. API callers do not need a local `git-lfs`
binary.

`POST` requires `version: 1`, an 8–128 character `idempotency_key`, matching
`source_sha` and `expected_head_sha`, the inspected primary `target_branch`,
reviewed entries, and the exact proposed object identities. Forge revalidates
the head, source, selection, repository authorization, verified LFS reachability,
and byte equality before committing `.forge/assets.json`, `.gitattributes`, and
standard pointer files. A successful response returns the derived migration
branch, commit, pull request, immutable receipt, and
`preview_behavior: "suppressed"`. Repeating the same idempotency key and body
returns the same receipt; a changed head or changed input fails closed.

```json
{
  "version": 1,
  "idempotency_key": "assets-01JEXAMPLE",
  "source_sha": "<40-hex-commit>",
  "expected_head_sha": "<same-40-hex-commit>",
  "target_branch": "main",
  "entries": [{ "path": "apps/main/public", "kind": "tree" }],
  "objects": [{
    "path": "apps/main/public/hero.mp4",
    "source_blob_sha": "<40-hex-git-blob>",
    "oid": "<64-hex-sha256>",
    "size": 12345678
  }]
}
```

## Limits and transport caveat

Forge Assets v1 has a **1 GiB single-object safety ceiling**. The effective
limit is the narrowest account entitlement, repository policy, Forge safety
ceiling, and transport/provider ceiling. Logical bytes count against the
account that can reach the object even when the physical digest is already
present globally.

The initial Worker-mediated basic upload path is capped at **100 MiB per
request**, within Cloudflare's current single-request body limit for the
deployed Worker plan. Forge rejects larger objects before promising an upload
action. Large-object resumable or multipart transfer is a separate transport
capability. The 1 GiB value is therefore an identity safety ceiling, not a
claim that every current account can upload a 1 GiB object in one HTTP request.

One Batch request accepts at most **100 objects** and **256 KiB** of request
metadata. Batch authorization itself remains separate from byte transfer.
Short-lived signed upload and verification tickets share a dedicated
**12,000 operations per hour** transfer budget rather than consuming ordinary
Git mutation capacity. A typical missing object uses one signed upload request
and one signed verification request. If a ticket expires or a bounded batch is
interrupted, submit the same OIDs to Batch again: already-verified objects need
no new bytes and remaining work receives fresh actions. Rebatching is the v1
resume/idempotency mechanism; it is not multipart upload for one oversized
object.

Release publication has separate provider limits. For example, connected
Cloudflare static assets retain Cloudflare's 25 MiB per-file ceiling even when
their source object was accepted by Forge Assets. See
[Deploy limits](limits.md).

For unchanged public binaries selected by the OpenNext preset, Forge plans the
provider manifest from the exact LFS SHA-256 and the normalized file extension.
It does not hydrate or buffer every object during the build. If Cloudflare asks
for bytes that are not already present, the runner verifies the R2 upload
checksum and size on that exact object and streams the provider-requested
multipart bucket with bounded base64 chunks. Provider asset hashes, byte
counts, and MIME identity remain deterministic, while aggregate release size
does not become aggregate runner memory use.

## Security and failure behavior

- Downloads require current repository reachability; a digest alone is not
  authority.
- Private repository existence, object existence, and cross-account reuse are
  not exposed to unauthorized callers.
- Upload size and SHA-256 must match the declared LFS object before the object
  becomes reachable.
- Unsafe paths, malformed pointers, mismatched sizes, digest failures, and
  missing exact-commit objects fail closed.
- Release manifests record logical digests and immutable receipts. Direct
  bucket access is not part of the repository or build contract.

See [Failure diagnostics](failure-diagnostics.md) for operator-facing failure
categories.
