# Changelog

> Every change in every Graft release, newest first, with the packages it touched. Dependency-only updates are left out.

*Every change in every release, newest first, except dependency-only updates.*

All Graft packages release together under one version number, so each release
below covers all of them. Each change names the packages it touched and links to
its commit. To move a project across a breaking change, follow
[Upgrading](/docs/upgrading), which says what to change for each one.

There are 13 releases and 130 changes on this page.

## 1.0.0-beta.9

Released November 1, 2026.

### Breaking changes

- **BREAKING:** `graft` exits with a code that says what kind of failure it was,
  and refuses a flag the command does not take.

  Before, every failure exited 1, and a flag meant for another command was
  silently ignored: `graft init --apply` succeeded and did nothing, which is how
  a dry run gets mistaken for a real one. Now:

  | Exit code | Meaning                                                                                            |
  | --------- | -------------------------------------------------------------------------------------------------- |
  | 0         | It worked                                                                                          |
  | 1         | It failed; the error says why and what to do                                                       |
  | 2         | The command was called wrong: an unknown command or flag, or a missing argument. Nothing ran       |
  | 3         | A person has to approve the action first (`DESTRUCTIVE_OP_REQUIRES_APPROVAL`)                      |
  | 4         | The credential is missing, wrong, or lacks the access this needs (`UNAUTHORIZED`, `TOKEN_INVALID`) |

  A script that checks for exit code 1 after a usage mistake must check for 2.
  An unknown command no longer prints the whole help on stdout; it prints the
  closest command on stderr:

  ```text
  graft: unknown command "complie".
  Did you mean `graft compile`? Run `graft --help` to list the commands.
  ```

  Every command that prints data now takes `--json`: `init`, `doctor`, `add`,
  `compile`, `check`, `content`, `compilations`, `asset put`, `studio invite`,
  `content import`, `pull`, `push`, `mirror`, `db migrate`, `migrate`,
  `harden`, `approvals`, `approve`, `deny`, `branch`, `branch create`,
  `branch drop`, `merge`, `ci changes` and `ci skip-build`. It prints one JSON
  object on stdout and sends every message to stderr. The object is indented on
  a terminal and on one line when piped, so `graft doctor --json` piped to a
  file is now one line. A command run with `--json` that fails prints one line
  of JSON on stderr: `{"error", "message", "fix", "details"}`, the shape every
  Graft error has. `graft check --json` lists each problem with its code and
  fix.

  Also new:

  - `graft help <command>` and `graft help <topic>` (`exit-codes`, `json`,
    `environment`).
  - `graft commands --json` describes every command, its arguments and flags,
    so an agent can learn the CLI in one call.
  - `graft add` with no item lists the primitives you can add, instead of
    failing with `REGISTRY_ITEM_NOT_FOUND`. `graft db --help`, `graft ci --help`
    and the other families print help for every member.
  - `--no-input` on `graft create` and `graft db claim`, or
    `GRAFT_PROMPT_DISABLED=1` for every command, never asks a question.
  - Color follows `NO_COLOR`, `FORCE_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE` and
    `TERM=dumb`.
  - An unexpected error prints one line instead of a stack trace;
    `GRAFT_DEBUG=1` prints the trace.
  - The CLI turns on Node's compile cache, which makes `graft check` about 8%
    faster on a 48-document project.

  Two error codes are added for these, with entries in the error reference and
  in `explain_error`: `USAGE_INVALID` and `INTERNAL_ERROR`.

  `cli`, `contracts`, `mcp` · [9a17a95](https://github.com/AndersonDesign1/graft/commit/9a17a95)

- Read and write content from the terminal. The new commands run the same MCP
  tools an agent calls, in the CLI's own process, so they check the schema,
  compile, and file approvals exactly as the agent's call would. With `--json`,
  each prints exactly what the tool returns.

  | Command                                                    | Tool                                                  |
  | ---------------------------------------------------------- | ----------------------------------------------------- |
  | `graft content list [collection]`                          | `list_collections`, `list_content`                    |
  | `graft content get <collection>/<slug>`                    | `get_content`, printed as the MDX file                |
  | `graft content search <query>`                             | `search_content`                                      |
  | `graft content write <collection>/<slug>`                  | `write_content`, from `--file` or `--data`            |
  | `graft content set <collection> <slug...> --field --value` | `write_content` for each document                     |
  | `graft content delete <collection>/<slug>`                 | `delete_content`                                      |
  | `graft drafts`, `drafts publish`, `drafts discard`         | `list_drafts`, `publish_drafts`, `discard_drafts`     |
  | `graft schema`                                             | `describe_schema`                                     |
  | `graft fn`, `fn describe`, `fn run`                        | `list_functions`, `describe_function`, `run_function` |
  | `graft explain <code>`                                     | `explain_error`                                       |
  | `graft mcp tools`, `graft mcp call <tool>`                 | any tool, by name                                     |

  `graft content get pages/home > home.mdx` and
  `graft content write pages/home --file home.mdx` round-trip a document. A
  delete, or a destructive function, exits 3 and its `fix` names the exact
  command to repeat with `--approval <id>` once a person has approved it.

  **BREAKING:** `graft content` now lists the documents from the content files
  (or from the database, where content is kept there) instead of reading the
  Postgres index. It works on the static engine with no `DATABASE_URL`, and
  `--json` prints `{ branch, collections: [{ collection, documents }] }`.

  `@usegraft/mcp` exports the client the CLI uses: `connectInProcess(server)`
  and `connectClient(transport)` return a `ToolClient` that calls tools by name
  and rejects with the GraftError a tool failed with
  (`toolResultToGraftError`).

  `cli`, `mcp` · [1a627ef](https://github.com/AndersonDesign1/graft/commit/1a627ef)

- **BREAKING:** `@usegraft/sdk-next` no longer supports Next.js 14. Its peer range is `next@^15.0.0 || ^16.0.0`, and CI no longer builds a Next.js 14 app. Next.js 14 gets no security fixes, and its last release, 14.2.35, has two critical remote code execution advisories that will not be fixed on 14.

  The adapter still runs on 14.2, so a project that cannot upgrade yet keeps working. pnpm and bun print a peer warning on install, and npm refuses unless you pass `--legacy-peer-deps`. `withGraft` now says that 14 is not supported and links to "Run Graft on Next.js 14" in the Next.js guide, which lists what differs on 14. Upgrade to 15.5.24 or 16.3.3 or later.

  The MCP `list_packages` tool says `@usegraft/sdk-next` is for Next.js 15 or 16.

  `mcp`, `sdk-next` · [1deb1a7](https://github.com/AndersonDesign1/graft/commit/1deb1a7)

### Features

- `graft api --paginate --slurp` prints every page as one JSON array once the last one arrives. `graft api -X DELETE` now asks before it sends anything when run on a terminal; with `--no-input`, or with no terminal, it still needs `--yes`. `graft help auth` explains `graft login`, saved tokens, `--remote`, `GRAFT_URL` and `GRAFT_TOKEN`.

  `cli` · [42213aa](https://github.com/AndersonDesign1/graft/commit/42213aa)

- Work with a hosted site from the terminal. `graft login <url>` checks a token
  made in Studio under Developer, Agents, and saves it in
  `~/.config/graft/hosts.json` (mode 600). The token comes from stdin
  (`--with-token`) or a prompt that does not echo, never from an argument.

  ```sh
  printf %s "$TOKEN" | graft login https://cms.example.com --with-token
  graft content list --remote
  graft api entries -f collection=posts --paginate
  ```

  - `--remote` on `graft content`, `graft drafts`, `graft schema`, `graft fn`,
    `graft mcp tools`, `graft mcp call` and `graft approvals` runs the same MCP
    tools on the site's `/api/mcp`. `GRAFT_URL` and `GRAFT_TOKEN` in the
    environment do the same without the flag, and `--local` overrides them.
    Neither is read from `.env`.
  - `graft api <path>` calls any Studio API route with the saved token: `-X`,
    `-f`/`-F` fields, `-H`, `--input`, `--include` and `--paginate`. It reaches
    only the logged-in site, follows no redirects, and needs `--yes` for
    `-X DELETE`.
  - `graft auth status` checks each saved token against its site,
    `graft auth token` prints one for piping, and `graft logout` forgets one.
  - A refused token exits 4 with `TOKEN_INVALID`; an unreachable site is the new
    `REMOTE_UNREACHABLE`. No token can approve an action, so a remote delete
    still exits 3 until a person approves it.

  `@usegraft/mcp` adds `connectRemote({ origin, token })`, a `ToolClient` over
  HTTP to a hosted `/api/mcp`.

  `cli`, `contracts`, `mcp` · [acb4a84](https://github.com/AndersonDesign1/graft/commit/acb4a84)

- `graft` now tells you when a newer Graft is on npm. After a command finishes, it prints the new version, the version you have, and a link to the upgrade guide on stderr. On a beta the notice is yellow, because beta releases often include security fixes. On a stable release it is cyan.

  The CLI asks the registry at most once a day and caches the answer under `~/.cache/graft` (or `XDG_CACHE_HOME`). It never fails a command, and waits at most 1.5 seconds for the registry. The check is off under `CI`, for `graft mcp`, and when `GRAFT_NO_UPDATE_CHECK=1` is set in the environment or the project's `.env`. `NO_COLOR` turns the color off.

  `cli` · [1deb1a7](https://github.com/AndersonDesign1/graft/commit/1deb1a7)

- `graft check` and `graft compile` skip the executable-MDX scan for a body they have already scanned. Results are kept under `$XDG_CACHE_HOME/graft/mdx-safety` (by default `~/.cache/graft/mdx-safety`), outside the project, and are keyed on the body and on the version of every package the scanner runs, down its whole dependency tree. A repeat `graft check` on a 50-page site went from about 1.7 s to about 0.3 s.

  `@usegraft/mdx-safety` adds `scanMdx`, the `MdxScanCache` interface, `mdxScanVersion`, `mdxScanPackages` and `scanVersionOf`, and the compiler's `validateDocuments`, `compile` and `compileStatic` take an optional `mdxScanCache`. Studio saves and MCP writes do not pass one, so they scan every body every time.

  `cli`, `compiler`, `mdx-safety` · [42213aa](https://github.com/AndersonDesign1/graft/commit/42213aa)

- Static projects no longer load the Postgres code. `@usegraft/db/static` is a new entry with the SQLite content index, the content diff, the search query check and branch scopes, and no drizzle-orm or Postgres driver behind it. Core, the compiler and the MCP server load the rest of `@usegraft/db` only inside the functions that talk to Postgres. On a 50-page static site, `graft content get` went from about 950 ms to about 480 ms. Everything in `@usegraft/db/static` is still exported from `@usegraft/db`.

  `cli`, `compiler`, `core`, `db`, `mcp` · [42213aa](https://github.com/AndersonDesign1/graft/commit/42213aa)

### Fixes

- These packages now need zod 4.6.7 or newer. Earlier 4.x releases can run out of memory while validating a very large array or set that has a size limit.

  `contracts`, `core`, `mcp`, `registry` · [3374aab](https://github.com/AndersonDesign1/graft/commit/3374aab)

## 1.0.0-beta.8

Released October 19, 2026.

### Breaking changes

- **BREAKING:** `graft compile` now stops on a `field.reference({ to })` that
  names a document that doesn't exist, with `CONTENT_REFERENCE_NOT_FOUND`.
  Until now a reference was checked only by `graft check`, so a product whose
  category had been deleted compiled, and the site found the gap at render
  time. A project that compiled with a broken reference fails now. Run
  `graft check` to list every one, then fix them or create the missing
  documents.

  The error names each file, field and missing slug in `details.references`:

  ```txt
  Error CONTENT_REFERENCE_NOT_FOUND: 1 reference(s) name a document that doesn't exist: products/wool-hat.mdx "category" points at categories/hats.
  ```

  Both content sources make the check, because both go through
  `validateDocuments`. In a database-sourced project, a publish refuses a draft
  whose reference isn't live, and refuses to publish the deletion (or a new
  slug) of a document that live documents still point at. Publish them together,
  changed to point elsewhere.

  A local Studio checks the same way: a save of an entry whose reference isn't
  in the index is refused, and so is deleting an entry other entries point at,
  before its file is removed. `checkRemoval` in `@usegraft/compiler` is that
  second check, and `compileDocuments` makes the first.

  `validateDocuments` takes a new `references` option. `"files"` checks targets
  among the files given, which is right for a whole tree, and `readDocs` uses it
  by default. A set of `collection/slug` keys also counts those as existing, for
  a caller checking part of a larger whole. Without the option, references are
  not checked. `liveDocuments` in `@usegraft/db` lists a branch's live
  documents for that set.

  `cli`, `compiler`, `db`, `mcp` · [b56d6b1](https://github.com/AndersonDesign1/graft/commit/b56d6b1)

- **BREAKING:** frontmatter is read as YAML or JSON only. gray-matter runs a `---js` frontmatter block through `eval`, so a contributor's Studio save, an agent's `write_content`, or a pull request built by `graft compile` could run code on the machine reading it. Every read now goes through `readFrontmatter` from the new `@usegraft/mdx-safety/frontmatter` entry, which refuses any other language with `INPUT_VALIDATION_FAILED` before parsing, whatever `mdxTrust` is. A lint rule stops gray-matter being imported anywhere else.

  **BREAKING:** `POST /api/studio/v1/changes/commit` and `POST /api/studio/v1/compilations/:id/revert` need `studio:publish`. They asked for `studio:write`, so a contributor could commit to the checkout, or roll it back, around the check Studio's Commit button makes.

  Also fixed:

  - `graft studio` with `GRAFT_DEV_TOKEN` gives the operator every admin scope, as its documentation says. Since contributors were stopped from committing locally, the token's missing `studio:publish` stopped the operator committing too.
  - Without a database, a media upload or delete is refused before the bucket is touched. Both used to change the bucket, then answer `NEEDS_DATABASE`.

  `cli`, `compiler`, `content-migrations`, `mcp`, `mdx-safety`, `studio` · [70321c7](https://github.com/AndersonDesign1/graft/commit/70321c7)

### Features

- `graft studio` starts without a database. Until now it stopped with
  `ENV_VAR_MISSING: DATABASE_URL is not set`, so a static project (`export const
index = "static"`) could not use Studio at all, and a Postgres project could
  not open it to fix a typo without a running database.

  Without `DATABASE_URL` you can find, edit, create, delete and commit content as
  before. In a static project each save also updates `.graft/index.db`, and
  Developer, Health compares the files with it; a static project never uses a
  database even when one is set, as with `graft mcp`. The views whose data lives
  in Postgres (approvals, the compile history, branches, people, agents, the
  media library, site settings) say they need a database and how to add one,
  and their API routes answer `NEEDS_DATABASE` (HTTP 503) with the same steps.

  For `createStudioHandler`, `db` is now optional, and `staticIndexPath` points a
  Studio at the static index:

  ```ts
  createStudioHandler({
    collections,
    contentDir,
    staticIndexPath: ".graft/index.db",
  });
  ```

  `GET /workspace` reports `database` and `index` (`"postgres"`, `"static"` or
  `"none"`), and `GET /tree` reports which index its states are measured
  against.

  Sign-in is unchanged. A `graft studio` on `127.0.0.1` asks nobody to sign in,
  with or without a database. Studio accounts and their sessions are kept in
  Postgres, so they still need one, and `graft serve --studio` still requires
  `DATABASE_URL`.

  `cli`, `studio` · [f1ed5d2](https://github.com/AndersonDesign1/graft/commit/f1ed5d2)

- References are checked before a write, not after it. A local Studio save, bulk edit, delete or discard, and MCP's `write_content` and `delete_content`, used to write or delete the file and then check references while updating the index. A refusal said nothing was saved while the file had changed, and the index no longer matched it. Now a refused write leaves every file as it was.

  - **`checkChange`** in `@usegraft/compiler` checks a change to a few files before it is written: the checks a compile makes of them, references included, and that nothing left as it is points at a document the change deletes or renames. It checks against the Postgres index, or the files when there is none.
  - **A stale index no longer refuses a good save.** A reference may name a file in the content folder the index hasn't caught up with, such as one a `git pull` brought in.
  - **On the static index or with none,** deletes and discards are checked too, and a reference already broken somewhere else no longer refuses an unrelated save. `graft compile` still reports it.
  - **`checkRemoval` takes `contentDir`,** and checks against the files there when it is given no `db`.
  - **Publishing in a database-sourced project checks references once it holds the content lock.** Two publishes at once, one deleting a category and one pointing a product at it, could each pass against the state before the other. `publishDrafts` in `@usegraft/db` accepts the index rows as a function of the live documents for this.
  - **A bulk edit is checked as a batch before anything is written,** and sets a field on at most 500 entries at once. A file that fails to write partway is reported with the others, and the ones written still reach the index.

  `compiler`, `db`, `mcp`, `studio` · [36472f3](https://github.com/AndersonDesign1/graft/commit/36472f3)

- A delete or a slug change checks references against the files, not an index that is behind them. After a `git pull` with no compile, a Studio or MCP delete could remove a document a pulled one pointed at, and the next `graft compile` failed. It could also refuse a delete over a document already gone from the files. Ordinary saves still check against the index, which keeps them fast. `checkRemoval` from `@usegraft/compiler` reads only the files: pass `contentDir` and drop `db` and `branchId`.

  Studio fixes from the review:

  - Set a field opens ready to set a value. Removing a field from every selected entry is a separate checkbox, so an empty value no longer removes it on one Enter.
  - The back link from an entry returns to the list as you left it: search, filters and sort.
  - A filter that came from a link, on a field with no filter menu, shows as a chip you can remove. It used to narrow the list with nothing on screen saying so.
  - A disabled Commit button says why on hover, on keyboard focus and to a screen reader, not only in a tooltip that never showed.
  - Home folds repeated saves into one row only within an hour of each other, so Monday's edits don't vanish under Friday's.
  - After a partial bulk failure, the selection keeps only rows the list still shows, so its count matches what the next action does.
  - A refresh that fails says the rows on screen are from before.
  - Opening a list, or Health and Settings, reads it once, not twice.

  `compiler`, `mcp`, `studio` · [e61014f](https://github.com/AndersonDesign1/graft/commit/e61014f)

- A local Studio save now updates the index for the file it wrote, instead of
  recompiling the whole content tree. On a 2,000-product catalog a save took
  1.6 seconds and now takes about 100 milliseconds.

  The save checks the file with the same rules as `graft compile` (the schema,
  MDX safety, unique slugs) and records a compilation with the commit it was
  read at, so the compile history still lists it. Other files are left as they
  are: a file changed outside Studio reaches the index on the next
  `graft compile`. One broken file elsewhere in the tree no longer makes every
  save fail.

  Two functions make this available outside Studio:

  ```ts
  import { compileDocuments } from "@usegraft/compiler";

  // Read, validate and project two files; a missing one comes off the index.
  await compileDocuments({
    contentDir,
    collections,
    db,
    paths: ["products/wool-hat.mdx"],
  });
  ```

  `projectDocuments` in `@usegraft/db` is the projection underneath, and it is
  the one a database-sourced publish already used. It changes only the rows it
  is given, refuses a slug another live file holds with `SLUG_NOT_UNIQUE`, and
  removes a row whose file no longer produces it.

  `compiler`, `db`, `studio` · [41c371f](https://github.com/AndersonDesign1/graft/commit/41c371f)

- `autoJoinAccount` creates a Studio account for an email that has never had one, with a given role. It returns null for an email that has or had an account, so someone an admin removed is not brought back, and null before the owner exists, so the first visit can still claim the Studio.

  `db` · [d854102](https://github.com/AndersonDesign1/graft/commit/d854102)

- `acceptInviteForEmail` uses the newest open Studio invitation for an email, for someone who proved they own the address another way, such as a verified email from GitHub, Google or single sign-on. It only creates or restores an account: for someone who can already sign in, an invitation is a password reset and still takes its link. An account made that way stores `NO_PASSWORD_HASH`, and `StudioAccount.hasPassword` says whether an account has a password.

  `db` · [ef9af08](https://github.com/AndersonDesign1/graft/commit/ef9af08)

- `@usegraft/sdk-next` runs on Next.js 14.2 and later, and in the Pages Router as well as the App Router. The peer ranges are now `next@^14.2.0 || ^15.0.0 || ^16.0.0` and `react@^18.2.0 || ^19.0.0`.

  - **The Pages Router.** Read in `getStaticProps` or `getServerSideProps` as before. `prepareMdx(body)` compiles a body there, with the same trust rule as `MdxBody`, and `MdxContent` renders the result in the server render and in the browser. It comes from a new entry, `@usegraft/sdk-next/mdx`, that carries no server code. A body that uses `import` is refused with `INPUT_VALIDATION_FAILED`, since the browser has no bundler to resolve it.
  - **React 18.** `createGraft` uses `React.cache` where React has it, and reads straight through where it does not, as in the Pages Router on Next.js 14.
  - **Next.js 14.** `withGraft` writes `experimental.serverComponentsExternalPackages` there, the name 14 reads, and `serverExternalPackages` on 15 and later. The adapter imports `next/cache.js`, which Node's own loader can resolve when the Pages Router loads it unbundled.
  - **Advisories.** Releases with open critical Next.js advisories are no longer refused at install. `withGraft` prints one warning naming the release to upgrade to: any 14, 15 before 15.5.24, or 16 before 16.3.3. Next.js 14 gets no more security fixes. Its last release, 14.2.35, is affected by 23 published advisories, two of them critical remote code execution, and each is fixed only in 15 and 16. Support for 14.2 is there so you can adopt Graft before you upgrade.

  CI now builds and serves a real Next.js 14.2.35 app beside 15 and 16, and on each checks a Pages Router page and that none of the scripts it sends to the browser carries the database, the index or the MDX compiler. The Next.js guide has a version table and a section on the Pages Router.

  `mcp`, `sdk-next` · [d06bbc3](https://github.com/AndersonDesign1/graft/commit/d06bbc3)

- `GRAFT_STUDIO_AUTO_JOIN` lets a whole email domain join a hosted Studio without invitations: `GRAFT_STUDIO_AUTO_JOIN="shop.example.com=contributor"`. The first time someone signs in with GitHub, Google or single sign-on and has a verified email at the domain, Studio makes them an account with the domain's role (contributor by default), shown in Settings, People, where an admin can change or remove it. Someone removed stays out. Admin is never given this way, free mail providers such as gmail.com are refused, and nobody joins before the owner exists. People lists the domains that join on their own.

  `studio` · [d854102](https://github.com/AndersonDesign1/graft/commit/d854102)

- A hosted Studio can sign people in with Google, and with any other OpenID Connect provider: Okta, Entra ID, Keycloak, your app's own Better Auth (with `@better-auth/oauth-provider`) and others. Set `GRAFT_GOOGLE_CLIENT_ID` and `GRAFT_GOOGLE_CLIENT_SECRET` for "Continue with Google", or `GRAFT_OIDC_ISSUER`, `GRAFT_OIDC_CLIENT_ID` and `GRAFT_OIDC_CLIENT_SECRET` (and `GRAFT_OIDC_NAME` for the button) for another provider. Studio runs the authorization code flow with PKCE and checks the ID token's signature, issuer, audience, expiry and nonce, and only a verified email counts.

  Settings, People is now the one place to add someone, however they sign in. A person invited there signs in with GitHub, Google or single sign-on as their Studio account, and their first sign-in uses the invitation: no link to pass on, no password to choose. GitHub matches any verified address on the person's account. Someone signed in as an account with no password sees no password to change in Settings.

  `studio` · [ef9af08](https://github.com/AndersonDesign1/graft/commit/ef9af08)

- Set one field on many entries at once. Select entries in a collection's list
  and choose **Set a field**: pick the field and its value with the same
  control the entry editor uses, and Studio saves each entry as a draft.
  Nothing goes live until it is published. Entries that can't be saved are
  listed in the dialog with the reason and stay selected; the rest are saved.

  The API behind it is `POST /api/studio/v1/entries/field`:

  ```json
  {
    "collection": "products",
    "slugs": ["wool-hat", "linen-shirt"],
    "field": "status",
    "value": "archived"
  }
  ```

  Each entry is validated and written exactly as `PUT /entry` would, against
  the version read just before the write. `"value": null` removes an optional
  field. A local Studio updates the index once for the whole batch. The answer
  lists `saved` entries with their new versions and `failed` entries with their
  error code, message and fix.

  On a phone, the bulk bar's buttons now wrap instead of running off the screen.

  `studio` · [6161f0e](https://github.com/AndersonDesign1/graft/commit/6161f0e)

### Fixes

- `tools/list` no longer fails with "Cannot read properties of undefined (reading 'push')" when the MCP SDK resolves an older zod than Graft's. The SDK takes any zod from 3.25 on, so a project whose own dependencies bring zod 4.2 to 4.4 handed the SDK a converter that crashed on Graft's schemas. Calling a tool by name still worked, but standard clients list tools first, so none of them could connect. The listing is now converted with the zod that built the schemas, and is otherwise the SDK's own, field for field.

  `serverInfo` reports the Graft release instead of `0.0.0`.

  Token messages speak to whoever holds the token. A revoked or unknown Studio token is told to get a live one from Developer, Agents, not to configure `createActorResolver`. A token missing a scope is told which access level carries it. An anonymous caller is told where tokens come from.

  `graft serve` beyond loopback no longer warns that MCP will refuse every caller: tokens made in Studio always work. It now says how callers get in.

  On `graft studio`, the Agents page no longer shows an MCP endpoint on this computer, which answered 404. It shows `npx graft mcp` for an agent on this machine, and `/api/mcp` wherever `graft serve` runs.

  `auth`, `cli`, `mcp`, `studio` · [c9868b0](https://github.com/AndersonDesign1/graft/commit/c9868b0)

- Studio no longer loops silently when opened over plain http on an address other than localhost. The session cookie is `Secure` there, so the browser dropped it and sign-in landed back on the form with no message. Studio now says sign-in needs https, or localhost, instead of showing a form that can't work, and an invite link stays unused until it can sign someone in.

  `graft doctor` and `setup_status` no longer make a missing `GRAFT_STUDIO_SECRET` the next step: it only matters once the site is online. That check now has its own id, `studio-secret`. With nothing else to do, doctor says the project is ready to work on and that the `!` lines are for going online. Once the secret is set, creating the owner account is still the next step.

  `graft --help` lists `graft studio setup-code` and `graft studio invite`.

  `cli`, `compiler`, `studio` · [9d26068](https://github.com/AndersonDesign1/graft/commit/9d26068)

- Studio's Publish button no longer says "Publish" for a moment before it says Commit or Submit for review. It now reads what it does from the workspace, which answers at once, instead of guessing until the drafts load.

  A collection's search, filters and sort are kept in the address, so a reload, the back button or a shared link shows the same rows. Its count, and Home's, no longer include entries deleted in a draft: they stay listed as Deleted until the deletion is published.

  Home's recent activity folds a run of the same person changing the same entries into one row ("Kyrus, 3 times"). Health and Settings read the content again after every save, publish, discard or delete, so their counts are never from before the last change. Studio has a favicon, so browsers stop asking the server for `/favicon.ico`.

  An agent's `publish_drafts` that commits to the production branch on GitHub now calls the site's deploy hooks under `graft serve`, as a database publish does. `createGraftMcp` takes an `onPublished` callback for this. A hook that fails never fails the publish.

  `cli`, `mcp`, `studio` · [9571702](https://github.com/AndersonDesign1/graft/commit/9571702)

- A save keeps the comment after an edited value. Changing `title: "Old" # shown in the tab` in Studio or over MCP used to write `title: "New"` and lose the note; it now writes `title: "New" # shown in the tab`. This holds for quoted and plain one-line values. A value that becomes a list or a map drops the comment, because it no longer has one line to sit on.

  `compiler` · [d016963](https://github.com/AndersonDesign1/graft/commit/d016963)

- A save no longer turns a one-line string into a folded `>-` block. js-yaml wrapped any string longer than 80 columns, so editing a long `description` rewrote it across three lines. Strings now stay on one line, and an edited one-line string keeps the quotes it had (`"…"` or `'…'`).

  A frontmatter that opens with a comment is now patched key by key like any other, instead of being rewritten whole, which dropped the comment, re-quoted every string and lost the blank line after `---`. Comments and blank lines between keys stay put when the key above them changes.

  `compiler` · [de7e5d6](https://github.com/AndersonDesign1/graft/commit/de7e5d6)

- Requires `@modelcontextprotocol/sdk` 1.31 or newer. Older versions could send an OAuth client's credentials to the wrong authorization server, and pulled in `proxy-addr`, `hono` and `ip-address` versions with known advisories.

  `mcp` · [5553879](https://github.com/AndersonDesign1/graft/commit/5553879)

- An agent's `write_content` and `delete_content` now update the index for the
  file they wrote, instead of recompiling the whole content tree, as a Studio
  save does. On a 2,000-product catalog a `write_content` call took about 1.5
  seconds and now takes about 110 milliseconds. The result's `changes` lists
  that one document. The static index is still rebuilt whole, and content kept
  in the database is unchanged.

  `mcp` · [41c371f](https://github.com/AndersonDesign1/graft/commit/41c371f)

- Fixes from a review of the Next.js 14 and Pages Router support.

  - **Unsafe URLs are dropped from restricted MDX.** `MdxBody` and `prepareMdx` remove any URL attribute whose scheme isn't `http`, `https`, `mailto` or `tel`, on Markdown links and on components. A `[Read more](javascript:…)` link rendered as a live link on React 18, which the Pages Router on Next.js 14 uses.
  - **`prepareMdx` reads imports from the syntax tree.** A code span or fenced block that only mentions `await import(` is no longer refused. Under `trust: "full"`, `import()` and `import.meta` are refused too, instead of compiling to code that always throws.
  - **`withGraft` merges both externals settings into the one this Next.js reads.** On 15 and later, a leftover `experimental.serverComponentsExternalPackages` no longer replaces `serverExternalPackages` and drops `@usegraft/registry`. On 14, a top-level `serverExternalPackages` is moved where 14 reads it.
  - **`withGraft` finds Next.js from a monorepo's root.** When no `next` is installed beside the config, it uses the one this package resolves.
  - **A prerelease of a patched release counts as before it,** so `16.3.3-canary.1` gets the upgrade warning.
  - The `FRAMEWORK_VERSION_UNSUPPORTED` fix says `revalidateContent` works on Next.js 14, 15 and 16. The Next.js guide says `MdxContent` needs `'unsafe-eval'` in a Content Security Policy.

  `sdk-next` · [334deef](https://github.com/AndersonDesign1/graft/commit/334deef)

- Lists can be walked from the keyboard. In a collection, j and k move through the entries from anywhere on the page (not while typing), and the arrow keys, Home and End once focus is in the list; Enter opens one. The list beside an open entry takes j and k too.

  Forms fit what they hold:

  - A string field that holds an address or a path (`url`, `githubUrl`, `thumbnail`, `email` and the like, or one with an `^https?` pattern) is a one-line input even when its limit is long. A URL allowed 2,048 characters used to open as a tall textarea.
  - The create dialog asks for the title first, then the rest in the schema's order.
  - A required group whose fields are all optional starts empty in the create dialog, so it no longer blocks creating an entry with "Links is required." until one of its optional fields is filled.

  On a phone, an entry's bar shows whether the save landed, and drops its own Commit button (the top bar's commits this entry with the rest) and the title, which the heading below shows in full.

  `studio` · [48e07b9](https://github.com/AndersonDesign1/graft/commit/48e07b9)

- A contributor on a local Studio can no longer commit. Locally there is no review step, so a commit landed on the checkout at once, whatever the role. The contributor's drafts now wait for an editor: the Commit buttons are off with the reason, and the API refuses with `UNAUTHORIZED` and `details.required: "studio:publish"`.

  A local commit is now authored by the signed-in person instead of whoever runs Studio, who stays the committer. With no message, it names the entries by title (`Update “Wool Hat”`) instead of by path, and the Publish sheet's placeholder shows that message.

  A refusal now speaks to whoever was refused. A person is told their role can't do it and to ask an admin. A token is told the scope it lacks and where to make one that has it. Studio shows these messages instead of "You're signed out".

  `studio` · [8a5c753](https://github.com/AndersonDesign1/graft/commit/8a5c753)

- Studio loads its editors when an entry opens, not with the app. Milkdown and
  CodeMirror were about 2.7 MB of the 3.3 MB every Studio page downloaded
  before it could show the home screen or a list. Now the first page loads
  0.66 MB (201 kB gzipped), and opening an entry adds the editor (1.66 MB, 532
  kB gzipped); the languages a code block highlights load only when one asks
  for them. The same split applies to `@usegraft/studio/panels`, whose bundle
  goes from 4.8 MB to 0.5 MB with the editor in a chunk of its own.

  `studio` · [118a339](https://github.com/AndersonDesign1/graft/commit/118a339)

- Settings, Keyboard lists the list shortcuts: J and K move down and up a collection's entries, the arrow keys, Home and End move through a list once it has focus, and Enter opens the selected entry.

  `studio` · [0263253](https://github.com/AndersonDesign1/graft/commit/0263253)

- An agent's request in the Inbox is titled with what it would do, such as "Delete projects/welup-digital", instead of the tool's name, and Approve asks once more before the agent may run it. The embedded `ApprovalQueuePanel` brings its own confirmation dialog.

  Deploy hooks are now called after Studio publishes to the production branch on GitHub, as they already were after a publish of content kept in the database. A commit on your own machine reaches no site and still calls none, and Settings now says so.

  Wording that didn't match what happens:

  - Deleting on your own machine says the file is removed now and the deployed site keeps it until you commit and push, instead of "stays live until you commit".
  - An entry nobody has edited since its last commit is dated by that commit, not by its file's modification time, which a clone or checkout resets.
  - The empty Drafts tab says there are no drafts instead of "Nothing matches."
  - Shortcuts in Settings and tooltips show Ctrl off a Mac, as the top bar already did.
  - The entry's details no longer show a "Version" that was the file's content hash.
  - An asset field's placeholder and an asset key error no longer name the shop example, and a missing key says it is missing instead of `"null" is not an asset key.`

  `studio` · [db2d4b6](https://github.com/AndersonDesign1/graft/commit/db2d4b6)

- Studio fixes from a review of the last release's UI work.

  - **The editor's menus and inline code look like Studio again.** Since the editor started loading on demand, Milkdown's stylesheet arrived after Studio's and won ties: the slash menu had no background or shadow, and inline code lost its font and fill. It now sits in a cascade layer under Studio's own styles.
  - **The publish sheet no longer re-renders without end while drafts load.** On a slow GitHub workspace it spun until the drafts answered, and forever if they never did. It shows a placeholder, or the error with Try again, and names the action from the workspace as the top bar does.
  - **A failed workspace request shows the error and Try again** in Agents, Branches, History, Media and the Inbox, instead of a placeholder that never ends.
  - **Deleting several entries goes past one that is refused,** says how many were deleted, keeps the refused ones selected, and refreshes the list either way.
  - **A bulk edit cut off partway refreshes the list** and says some entries may have been saved.
  - **If the editor's code fails to load, only the editor says so,** with Reload, and the entry's fields keep working.
  - **Keyboard:** j and k no longer move focus behind a confirm dialog. Arrow keys from a row's checkbox move from that row, not from the first.
  - **Words:**
    - Errors read as two plain sentences, with no em dash.
    - The "add a database" steps include removing `index = "static"`.
    - Health shows no drift columns when there is no index.
    - Media doesn't offer uploads without a database.
    - Discard on entries with no drafts says there is nothing to discard.

  `studio` · [22c6e47](https://github.com/AndersonDesign1/graft/commit/22c6e47)

## 1.0.0-beta.7

Released October 13, 2026.

### Breaking changes

- Studio now uses the docs' type, color and icons. IBM Plex Sans and Plex Mono
  replace Geist and Instrument Serif, and mono is kept for identifiers (a slug,
  a path, a field name) instead of labels. Labels are sentence case with no
  letter-spacing, nothing is under 13px, and weights are 400, 500 and 600.

  Color follows the docs' rule, monochrome chrome and colorful content: states
  are a mark and a word on the neutral ramp, amber is kept for something waiting
  on a person, red for errors and deletes, and diffs keep their code colors.
  Collections are marked by an icon in the text color, not a tinted tile.

  Icons are Lucide, at 16px and a 1.75 stroke, as in the docs.

  **BREAKING (panels embed only):** if you embed `@usegraft/studio/panels`,
  install `lucide-react` in place of `@phosphor-icons/react`. It is the optional
  peer the panels now import. `graft studio` and `graft serve --studio` need
  nothing.

  `studio` · [132107f](https://github.com/AndersonDesign1/graft/commit/132107f)

### Features

- Studio gets a media library, agent tokens, and the site's settings with deploy
  hooks.

  - **Media.** A Media page lists the files in the asset store that Studio
    uploaded or an entry uses. Upload, search, write alt text, see which entries
    use a file, and delete one nothing uses. Image fields have Choose…, and a gallery's Add image, which pick from the same library and take the file's alt text when the field has none. New routes under `/api/studio/v1/assets`.
  - **Agents.** Admins make a token per agent under Developer, Agents, with
    Read, Write drafts or Publish access. The token is shown once and only its
    hash is stored. `graft serve` accepts it on `/api/mcp`, the Studio API and
    functions, and revoking it takes effect on the next request. No token can
    decide approvals. `@usegraft/auth`'s resolver gains a `tokens` option for
    this.
  - **Site and deploy hooks.** Settings has the site's name and address, and
    deploy hooks: URLs called with a `POST` after every publish of
    database-sourced content, with an optional bearer secret. Each hook shows
    what it answered last, and Send a test calls it now. A failing hook never
    undoes a publish. `@usegraft/compiler` exports `fireDeployHooks`.
  - New scope `studio:settings`, held by admins.
  - A Studio route that needs a table the database doesn't have answers
    `MIGRATION_REQUIRED` (503) with the fix, instead of failing the request.

  Run `graft db migrate` (migration 0013 adds `assets`, `agent_tokens`,
  `site_settings` and `deploy_hooks`). Run `graft harden <role>` again if you
  use a runtime role.

  `auth`, `cli`, `compiler`, `db`, `studio` · [b086a62](https://github.com/AndersonDesign1/graft/commit/b086a62)

- Content can now live in your Postgres database instead of in git. Add
  `export const content = { source: "database" }` to `graft.config.ts` and run
  `graft content import` to load your files once.

  Studio and agents then save drafts to the database, and publishing puts a
  change on the live site straight away, with no rebuild and no GitHub setup. A
  hosted Studio needs only `DATABASE_URL`. Every publish is checked exactly as
  `graft compile` checks a file (the schema, slug uniqueness and MDX safety), and
  a draft that would fail compile never goes live. `graft compile` reads the
  published versions, never the files.

  There is one draft per document, shared by everyone who edits it. Two people
  or an agent saving the same document from the same version can't overwrite
  each other: the second save gets `CONTENT_CONFLICT`. Someone without publish
  rights submits for review instead, and the request closes once everything in
  it is live.

  Git stays the default and works as before. A project uses one source or the
  other, never both. Reverting to a commit, `graft merge` and content migrations
  work on files, so in a database-sourced project they refuse with the new
  `CONTENT_SOURCE_MISMATCH`.

  `graft db migrate` adds three tables (`content_revisions`, `content_heads`,
  `content_reviews`), and `graft harden` grants the runtime role access to them.

  For code that reads review requests from a content store: `ReviewRequest` now
  has an `id`, and its `number` and `url` are optional, since a review in the
  database is not a pull request. The `review` that MCP `list_drafts` and
  `publish_drafts` return follows the same shape.

  `cli`, `compiler`, `contracts`, `db`, `mcp`, `studio` · [c997e35](https://github.com/AndersonDesign1/graft/commit/c997e35)

- A new site in one command, and a checklist that says what to do next.

  - `npm create graft` (the new `create-graft` package) runs `graft create`: it
    asks what kind of site (blog, docs, shop, portfolio or blank), how people
    will edit it (online in Studio, or as files in git) and which database to
    use, then writes the site with a Next.js app, installs it, migrates, loads
    the example content and compiles. Every question has a flag, and `--yes`
    takes the defaults, so an agent can run it with no prompts.
  - `--database new` makes a free Postgres with no account. It lasts 72 hours
    unless claimed: `graft db claim` prints the link, waits for the claim, and
    saves the new connection string to `.env`. Any Postgres 13 or newer works
    instead.
  - `graft doctor` and the MCP tool `setup_status` check the database and its
    version, the schema, a database waiting to be claimed, content, the index
    and Studio sign-in, and end with the one thing to do next.
  - A new site gets an `AGENTS.md`, and the docs serve a setup guide for agents
    at `/start.md`.

  `cli`, `compiler`, `create-graft`, `mcp` · [95ecc3e](https://github.com/AndersonDesign1/graft/commit/95ecc3e)

- Moving content between the database, your files and git.

  - `graft pull` writes what is published into your files, and `graft push`
    publishes the files you changed. Graft remembers what each file was pulled
    at (`.graft/sync.json`), so a pull never overwrites your changes, and a push
    is refused with `CONTENT_CONFLICT` if someone published that document since.
    `--force` settles a conflict either way.
  - `graft dev` tells you when someone publishes, so you know your files are
    behind. Agents get the same answer from the new MCP tool `sync_status`.
  - `content.mirror` copies what is published into GitHub: in batches from
    `graft serve` (`mode: "batch"`, every `everyMinutes`), or after every publish
    (`mode: "each"`). `graft mirror` does one run. A batch is one commit, or one
    pull request merged straight away when the branch allows it. A pull request
    that waits on checks or a review stays the only one: later runs add to it
    until it merges.
  - `graft check` runs every check a publish makes on the files, with no
    database, and now also finds references to documents that don't exist.
  - `graft ci changes`, `graft ci skip-build` and `graft ci init` keep CI cheap
    on content-only changes. `skip-build` compares with the previous deploy, and
    builds when it can't tell which that was.

  For git-sourced projects hosted on GitHub, some publishes now open a pull
  request even for an editor: deletions and renames, and changes over 200 lines.
  Studio says why. Change the rule with `content.review` (`deletes`,
  `collections`, `overLines`), or turn it off with `review: false`.

  `graft db migrate` adds the `content_mirror` table.

  `cli`, `compiler`, `db`, `mcp`, `studio` · [a761a13](https://github.com/AndersonDesign1/graft/commit/a761a13)

- Studio accounts: people sign in to a hosted Studio with an email and a password.

  - The first visit creates the owner account. On a server anyone can reach,
    it asks for a setup code first, which `graft serve` prints when it starts
    and `graft studio setup-code` prints from the site's settings.
  - Admins invite people from Settings, People: an email and a role make a link
    to pass on. The same list changes roles, makes a link to set a new password,
    and removes access, which signs that person out at once.
  - Account sessions live in the database, so signing out, removing someone or
    changing their role takes effect on their next request. Changing your
    password signs out your other browsers. Five wrong passwords in a row lock
    an account for 15 minutes.
  - Admins get the new `studio:people` scope. GitHub sign-in and
    `graft studio invite` links work as before.
  - New tables `studio_users`, `studio_sessions` and `studio_invites`: run
    `graft db migrate`, and `graft harden <role>` again if you use a runtime role.

  `cli`, `db`, `studio` · [2d97fda](https://github.com/AndersonDesign1/graft/commit/2d97fda)

- Studio keeps each entry's history, puts every decision in one inbox, and
  shows what was published lately.

  - **History.** The entry's panel has a History tab. It lists every published
    version with who made it and when. Open one to see how the entry differs
    from it now, then restore it as a draft. History comes from the published
    revisions when content is in the database, the production branch's commits
    on GitHub, and the checkout's commits locally. New API routes:
    `GET /entry/history` and `GET /entry/version`.
  - **Inbox.** Changes sent for review and agents asking before a destructive
    call share one inbox, with a count in the sidebar. When content is in the
    database, someone who can publish publishes a review from there. Approvals
    move out of the Developer views, and old `#/dev/approvals` links open the
    inbox.
  - **Home.** A "Recently published" feed lists who published what, with
    links to the entries (`GET /activity`).
  - Content stores gain optional `history`, `readVersion` and `activity`
    methods. `DatabaseStore` and `GitHubStore` implement them, and
    `@usegraft/db` adds `publishedHistory`, `recentlyPublished` and
    `readPublishedRevision`.

  `compiler`, `db`, `studio` · [e8b55ac](https://github.com/AndersonDesign1/graft/commit/e8b55ac)

- Studio's developer views follow the docs. Health leads with its numbers
  without boxes or a decorative chart, lists collections, recent compiles,
  approvals and branches as plain tables, and has a Compile button again, so
  out-of-date content is fixed where Studio shows it. Schema shows types as
  plain identifiers and "Required" as a word instead of a pill, and the sign-in
  page uses the `graft. / Studio` wordmark. States read "Out of date", "Never
  compiled" and "File missing" in place of "Drifted", "Not indexed" and
  "Orphaned".

  `studio` · [21b232a](https://github.com/AndersonDesign1/graft/commit/21b232a)

- Studio's editor screens follow the docs. Home opens with what is waiting for
  you (drafts, an agent waiting on an approval, a pull request in review) and a
  plain table of collections, in place of the printed-paper front page. The
  collection list marks drafts with a dot and a word instead of a pill. The
  entry page sits in the docs' column at the docs' reading size, with no card
  around it.

  One word for one state: an edit not yet committed or published is a Draft
  everywhere, and every sentence uses the workspace's own verb, so a Studio on
  a laptop says "commit" and never "publish".

  `studio` · [fe7d03b](https://github.com/AndersonDesign1/graft/commit/fe7d03b)

- Studio's frame and popups now match the docs. A 56px top bar runs across the
  page with the `graft. / Studio` wordmark and the branch on the left, search
  centred, and Commit (or Publish) and the account menu on the right. The
  sidebar sits under it on the page colour. Below 768px the sidebar opens from a
  menu button.

  Popups share one material: the elevated surface and a 10px radius, opening in
  160ms and closing in 100ms. Dialogs and sheets add a dimmed backdrop with no
  blur and a visible close button; the command palette opens instantly and closes
  on Escape or a choice. Deleting or discarding asks in a confirm dialog that starts on
  Cancel and names the action ("Delete post") in place of the browser's
  `confirm()`. The command palette shows its keys, menus carry icons, toasts
  come one at a time, and committing ends with a plain check instead of an
  animation.

  `@usegraft/tokens` adds `--scrim`, the colour behind a dialog or sheet.

  `studio`, `tokens` · [b146045](https://github.com/AndersonDesign1/graft/commit/b146045)

- Studio opens an entry as panes. The collection's list stays beside the
  document, with the open entry marked, so the next entry is one click away and
  the arrow keys move through the list. A new inspector beside the document shows
  the entry's status, where it is saved, its file and version, and what changed;
  ⌘\ or Ctrl+\ hides it for a full-width page. Under 1280px it opens over the
  document instead, and the list gives way under 1024px. Every pane header is
  the same height, and all of Studio's spacing now comes from the `--space-*`
  scale.

  `studio` · [cafd545](https://github.com/AndersonDesign1/graft/commit/cafd545)

- The light scheme moves from ivory to chalk paper. `--paper-0` is now `oklch(0.992 0.002 95)` (about `#fdfcfb`) and `--paper-2` is `oklch(0.966 0.003 95)` (about `#f4f4f1`), on the same warm hue as the ink, so light mode reads white without going cold. Light borders are lighter to match (`--border` 0.11, `--border-strong` 0.24). Dark mode does not change. Anything that reads `--paper`, `--background` or `--border`, including Studio's light preview, picks this up with no code change.

  New type-role tokens set size, leading and tracking together: `--type-title`, `--type-section`, `--type-subsection`, `--type-lede` and `--type-meta`, each with its `--leading-*` and, for headings, `--tracking-*`. `--weight-regular`, `--weight-medium` and `--weight-semibold` name the only three weights in use. `--type-wordmark` and `--tracking-wordmark` set the docs top-bar "graft." wordmark. The landing page keeps its own wordmark for now.

  `tokens` · [f8c14f4](https://github.com/AndersonDesign1/graft/commit/f8c14f4)

### Fixes

- Opening the account, sort or filter menu no longer turns Studio into a blank
  page. Each menu put its label outside a group, which the menu library refuses,
  and nothing caught the error. Menu labels now always come with their group,
  menu items sit on one line, and a rendering error shows what went wrong with a
  Reload button instead of a white page.

  `studio` · [d6aec19](https://github.com/AndersonDesign1/graft/commit/d6aec19)

## 1.0.0-beta.6

Released October 11, 2026.

### Fixes

- One loopback list decides every "https unless loopback" question: `@usegraft/compiler` exports `LOOPBACK_HOSTS` and `isLoopbackHost`, and the revalidate webhook, Studio's `Secure` session cookie and `graft studio invite` all use them, so the invite check and the cookie can never disagree about a host. Paths under a regular file now read as absent instead of failing with `ENOTDIR`, and deleting a document that is already gone succeeds.

  `cli`, `compiler`, `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- `graft studio invite` refuses a plain `http://` address unless it is `localhost`, `127.0.0.1` or `[::1]`. The link carries a sign-in token, so plain http sent it unencrypted, and the session cookie is `Secure` off loopback, so the sign-in failed anyway. The usage message for a missing email now lists `--name` and `--days` too.

  `cli` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- Two error-handling fixes. `GRAFT_REVALIDATE_URL` with a `user:password@` part is refused at startup without repeating the credentials, and a network error that quotes the webhook URL has its query stripped, so a token in the URL no longer reaches logs, agents or Studio toasts. Creating a GitHub draft branch now reads only GitHub's "already exists" answer as a race: any other 422, such as a missing commit, fails with `REMOTE_STORE_FAILED` and GitHub's reason, where it used to retry to exhaustion and report the document busy.

  `compiler` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- Paths inside the content directory now refuse a dangling symbolic link. The check followed the link, so a link whose target did not exist read as absent, and a Studio or MCP write then created the target outside the content directory. A delete on a read-only filesystem now fails with `CONTENT_TREE_READ_ONLY`, the same error a save gives, instead of a raw `EROFS`.

  `compiler` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- A save to a GitHub draft can no longer be lost while the draft is being emptied. Publishing everything or discarding the last change used to read the draft branch and then delete it, and a save that landed in between was deleted with it after reporting success. The draft is now emptied with a fast-forward that fails if the branch moved, so the branch stays, holding no changes, and the next save reuses it.

  Invite-link editors get new draft branch names. Their key joins the hash with `--`, which a GitHub login cannot produce, so a login shaped like an email's key (`ana-lima-shop-test-0123456789`) no longer shares that person's draft. A draft saved under the old name before upgrading stays on GitHub as a branch: publish or discard it first, or merge it by hand.

  `compiler`, `mcp`, `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- `GITHUB_STORE_ENV` now lists the host commit variables the store reads (`VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, `RAILWAY_GIT_COMMIT_SHA`, `COMMIT_REF`, `GITHUB_SHA`), exported on their own as `DEPLOYED_SHA_ENV`. A server that allowlisted its environment from the old list dropped them, so Studio could not tell which commit was deployed.

  `compiler` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- Field descriptors now match what the validator checks. A pattern's flags are kept in the new `constraints.patternFlags`, so Studio checks `/^[a-z]+$/i` case-insensitively like the server does instead of flagging valid values. Constraints are listed only for the field types that enforce them: `field.string({ min: 10 })` validates nothing, so `describe_schema` no longer advertises a minimum for it.

  `contracts`, `core`, `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- `explain_error` for `REMOTE_STORE_FAILED` no longer promises that a failed save did not happen. A refusal means that one request was not applied, but a publish can move production before a later step is refused, and a network error can arrive after GitHub applied the request, so agents are told to check the branches before retrying. A 404 now also names its other cause: a private repository the token or GitHub App cannot see.

  `mcp` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- A hosted Studio's change diff keeps its memory bounded. It kept a copy of the whole search frontier for every changed line, so a large rewrite (two 10,000-line versions with nothing in common) needed gigabytes and could take the server down. It now skips the unchanged start and end, keeps only the part of the frontier each step uses, and past 2,000 changed lines falls back to every old line removed and every new line added. As before, the drawer renders at most 600 lines and marks a longer diff as truncated. A hunk whose old or new side is empty now starts at the line before it, as git does (`-0,0` for a new file).

  `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- `@usegraft/studio` also exports `ContentChangeNotice` and `ReviewRequest`, the two types its editor DTOs refer to, so typing a response needs one import instead of a second one from `@usegraft/compiler`.

  `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

- Studio fixes from review:

  - Discarding a local file that was staged as new and then deleted works, including in a repository with no commits yet. It used to try to restore it from HEAD, where it never was.
  - Local drafts report each changed file's version, as GitHub drafts do. Only a deletion has none.
  - A failed GitHub sign-in returns to the page the person started from, branch and hash kept, instead of Studio's front page. That needs the sign-in's own state cookie: a callback without a valid one still lands on the front page.
  - Slugs drop every combining mark, not only those in U+0300 to U+036F, so a title with one no longer gains a hyphen mid-word.
  - "News", "series", "species", "media" and "data" keep their form in the singular ("New news" became "New new").
  - `ContentTreePanel` follows a change to its `branch` prop. It kept showing the first branch.
  - The first render after a view starts loading shows the loading state, not an empty page.
  - The vermilion scale uses the same lightness steps as the other hues, so unpublished-state colour reads at the same brightness.
  - `WorkspaceDto.sessions` is documented as what it is: whether the request carried a session cookie.

  `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74)

## 1.0.0-beta.5

Released October 10, 2026.

### Breaking changes

- **Breaking:** `graft serve --studio` no longer serves `GET` and `PUT /api/studio/v1/document`. Use `GET` and `PUT /api/studio/v1/entry`, which read and save the same files and carry a version for conflict detection. See Upgrading.

  `cli` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- **Breaking:** remove `GET` and `PUT /api/studio/v1/document` and the `DocumentDto` type. Use `GET` and `PUT /api/studio/v1/entry`, which read and save the same files, carry a version for conflict detection, and work against a GitHub store too. An entry deleted in a draft now stays in `/entries` as `status: "deleted"`, and `GET /entry` returns its published copy so it can be read or restored.

  `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

### Features

- MCP writes through the content store. On a `graft serve` that writes to GitHub (`GRAFT_GITHUB_REPO` set), `write_content` and `delete_content` commit to the connection's own draft branch instead of the read-only files, and three tools appear: `list_drafts`, `publish_drafts` (a pull request, or a commit to the production branch with the new `content:publish` scope) and `discard_drafts`. Agents and Studio editors share one draft model.

  `cli`, `mcp` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- Studio has an editor API on top of the content store. `GET /api/studio/v1/entries` returns a page of a collection searched, filtered, sorted and faceted on the server, from a listing that re-parses only files whose size or mtime changed, so a catalog of thousands stays fast. `GET/PUT/POST/DELETE /api/studio/v1/entry` and `POST /api/studio/v1/entry/duplicate` read, save, create (slug from the title), delete and copy entries; a save that sends the version it read as `baseVersion` is refused with `CONTENT_CONFLICT` when that version is stale (a save without one is unconditional). `/api/studio/v1/drafts`, `/drafts/diff`, `/drafts/publish` and `/drafts/discard` list unpublished changes, diff them, publish them and throw them away: a commit in your checkout locally (you push it yourself), a commit on the production branch or a pull request on GitHub. `GET /api/studio/v1/workspace` says where saves land and what Publish does for the caller. `graft serve --studio` writes through GitHub when `GRAFT_GITHUB_REPO` is set along with a credential (`GRAFT_GITHUB_TOKEN`, or a GitHub App). Locally, every save, create, delete and discard compiles, then calls `onContentChange` when one is configured and the content changed, so the app refreshes the way it does for the old routes; a failed refresh comes back as `refresh: { ok: false }` and Studio warns.

  `cli`, `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- A hosted Studio now has sign-in. Set `GRAFT_STUDIO_SECRET` and people sign in with GitHub (`GRAFT_GITHUB_CLIENT_ID` and `GRAFT_GITHUB_CLIENT_SECRET`) or with an invite link from `graft studio invite <email> --role editor`, into a signed, HTTP-only session cookie. `GRAFT_STUDIO_EDITORS` lists who may sign in with GitHub and with which role; without it, anyone with write access to the content repository can. An invite link signs its holder in with the role it was made with, whether or not they are on that list. Roles map to scopes: `viewer`, `contributor` (drafts, publishing opens a pull request), `editor` (adds the new `studio:publish` scope) and `admin` (adds `approvals:decide`). Bearer tokens keep working beside sessions.

  `cli`, `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- Add the content store: where authored content is read from and written to, behind one interface. `FilesystemStore` is today's behaviour. `GitHubStore` writes through the GitHub REST API, so a hosted surface on a read-only filesystem can save: each editor drafts on their own branch, and publishing lands one commit on the production branch authored by the editor, or opens a pull request. Every write can carry the version it was read at and is refused with the new `CONTENT_CONFLICT` code when the document changed underneath it, which covers a second tab or an agent writing under the same identity. Editors draft apart, so between two editors the guard is at publish: it refuses to overwrite a document production changed since the draft began until someone chooses to keep the draft or take the published version. Credentials are a fine-grained token (`GRAFT_GITHUB_TOKEN`) or a GitHub App (`GRAFT_GITHUB_APP_ID`, `GRAFT_GITHUB_APP_PRIVATE_KEY`); `githubStoreFromEnv` reads the configuration. GitHub failures surface as `REMOTE_STORE_FAILED`: a refusal carries GitHub's own message and status, and an unreachable GitHub says so. `@usegraft/compiler/testing` exports an in-memory GitHub for tests.

  `compiler`, `contracts`, `mcp` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- Fields can say how an editor should see them. `field.select({ options })` holds one value from a fixed list, `field.reference({ to })` holds the slug of a document in another collection, every field takes a `label`, and `field.number({ format: "money" })` declares an amount in the smallest currency unit. `describe_schema` now includes each field's label, limits (`min`, `max`, `int`, `maxLength`, `pattern`, `maxItems`), options and reference target, so Studio and agents see the rules the validator applies.

  `contracts`, `core` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- Studio is rebuilt for editors. Content comes first: a home page with what is waiting to publish and the collections as a table of contents, a table per collection searched, filtered, sorted and paged on the server with bulk publish, discard and delete, and an entry editor that shows the title in the site's display face, every field with its label, limits and help, and the content in the rich editor. Fields edit in place, including choices, money, references with a search picker, tags, image galleries, nested groups and repeatable lists such as product variants; "Edit in Raw MDX" is gone as the only way to change them. Problems are reported on the field in plain words before a save, and a change made elsewhere since the entry was opened is reported instead of overwritten. Publishing happens in one sheet that names what will happen (publish, submit for review, or commit), lists the changes as entries with their changed fields ("Price: $139.00 → $145.50"), resolves conflicts, and discards. New entries ask only for required fields. A hosted Studio shows a sign-in screen. Index health, schema, history, branches, approvals and settings move under Developer. `ContentTreePanel` now renders the full editor.

  `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

### Fixes

- `graft serve` passes every `Set-Cookie` header through. Its Node adapter copied response headers one value per name, so a response setting two cookies arrived with one, and a hosted Studio's sign-in lost the session cookie.

  `cli` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- Committing a staged rename from Studio works: the old path is recorded as removed instead of passed to `git add`, which refused it because the file is gone.

  `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3)

- `@usegraft/studio` exports the types of the editor API: `EntryDto` (the replacement for the removed `DocumentDto`), `EntryList`, `EntrySummary`, `SaveEntryResult`, `WorkspaceDto`, `DraftsDto`, `DraftDiffDto`, `PublishResultDto` and the types they use. Code that calls `/api/studio/v1/entry` can type its responses without copying them.

  `studio` · [3b396c0](https://github.com/AndersonDesign1/graft/commit/3b396c0)

## 1.0.0-beta.4

Released October 9, 2026.

### Features

- Graft now tells your app when content changes, so its cache refreshes without
  a manual call.

  Before this, an agent's `write_content` or a Studio save updated the index and
  returned the ChangeSet to the caller. The app kept serving its cached pages
  until someone called its revalidate route by hand.

  **Webhook.** Set `GRAFT_REVALIDATE_URL` and `GRAFT_WEBHOOK_SECRET` where
  `graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate`
  runs. After each local write that changes the index, Graft POSTs
  `{ branch, gitSha, changes }` with `Authorization: Bearer <secret>`, the body
  the documented revalidate route already reads. The URL must use https, except
  on loopback (`localhost`, `127.0.0.1`, `[::1]`), and redirects are refused. A URL without a secret stops the
  command before it connects to anything. `graft compile` does not call the
  route, because a deploy compiles before the new version is live. A write to a
  GitHub-backed store is a draft and calls nothing: the app refreshes when a
  publish reaches the production branch and the host rebuilds.

  **Hook.** `createGraftMcp`, `createGraftMcpHandler`, `createStudioApiHandler`
  and `createStudioHandler` take `onContentChange(event)`. An app that mounts the
  MCP endpoint itself refreshes in-process:

  ```ts
  createGraftMcpHandler({
    // …
    onContentChange: ({ branch, changes }) => {
      revalidateContent(branch, changes);
    },
  });
  ```

  `@usegraft/compiler` exports the pieces: `notifyContentChange`,
  `createRevalidateWebhook`, `revalidateWebhookFromEnv` and the
  `ContentChangeEvent` / `ContentChangeListener` types.

  **A failed refresh never fails the write.** The write already landed. The
  server logs the new `REVALIDATE_FAILED` error, `write_content` and
  `delete_content` return `refresh: { ok: false, error, message, fix }`, and
  Studio shows a warning toast. Writes that change nothing do not call the
  listener.

  Also fixed: saving a document with an empty body a second time appended a
  newline to the file, so it projected as a change. `composeDocument` now leaves
  a body-less document byte-identical.

  `cli`, `compiler`, `contracts`, `mcp`, `studio` · [4831a77](https://github.com/AndersonDesign1/graft/commit/4831a77)

## 1.0.0-beta.3

Released October 8, 2026.

### Breaking changes

- `graft asset put` refuses a key that already holds a file, like `put_asset`.

  The MCP `put_asset` tool has refused a taken key with `ASSET_EXISTS` unless the
  caller passed `overwrite: true`. The CLI did not check: it replaced whatever was
  stored under the key. The store keeps no version history, so the old file was
  gone, and every page pointing at the key showed the new one.

  The CLI now checks first and fails with the same `ASSET_EXISTS` error. Pass
  `--overwrite` to replace the file on purpose.

  Both also make the upload itself conditional. `Storage.put` from
  `@usegraft/assets` takes `{ ifAbsent: true }`, which sends
  `If-None-Match: *`, and throws `AssetKeyTakenError` when the store answers 412. So the store refuses a key that a concurrent upload took between the check
  and the write, or that a credential allowed to upload but not read cannot see.
  A store that ignores the header writes as before, behind the existence check.

  **Breaking:** a script that re-uploads to the same key now fails until it adds
  `--overwrite` or picks a new key.

  `assets`, `cli`, `mcp` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957)

- `@usegraft/sdk-next` now works on Next.js 15 as well as 16, and needs a
  release with the 2026 security fixes. The peer ranges are
  `next@^15.5.24 || ^16.3.3` and `react@^19.0.0`. CI builds and serves a real app
  on each major, type-checks the Next.js guide's examples against it, and checks
  reads, MDX, targeted refresh and `updateContent` in a real Server Action.

  The package used to declare Next.js 15 or newer while calling APIs that only
  Next.js 16 has. What changed for you:

  - **Breaking:** the peer range now starts at 15.5.24 and 16.3.3, the first
    releases that fix the critical Next.js advisories published in 2026. Upgrade
    Next.js if you are on an older 15.x. Next.js 14 is not supported: it no
    longer gets security fixes, and those advisories are still open on it.
  - `createGraft`, `MdxBody` and `revalidateContent` work on 15 and 16. On
    Next.js 15, tag your reads with `unstable_cache(fn, keys, { tags: tagsFor(...) })`.
    The Next.js guide shows both paths.
  - `updateContent` still needs Next.js 16. On 15 it now throws a `GraftError`
    with code `FRAMEWORK_VERSION_UNSUPPORTED` whose `fix` names
    `revalidateContent`. Before, the package imported `updateTag` by name, and
    Next.js 15 does not export it.
  - `@usegraft/sdk-next/config`, `@usegraft/sdk-core/db` and
    `@usegraft/studio/panels` now resolve their types under
    `moduleResolution: "node"`, the setting Next.js 15 writes into a new
    tsconfig. Before, those imports had no types there, and reads came back
    typed as `unknown`.

  `FRAMEWORK_VERSION_UNSUPPORTED` is a new error code, with an entry in the error
  reference and in `explain_error`.

  `contracts`, `mcp`, `sdk-core`, `sdk-next`, `studio` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c)

### Features

- Add `graft compile --json`. It prints one JSON object on stdout and nothing
  else: `{ "branch", "gitSha", "changes" }`, where `changes` is the compile's
  ChangeSet. The summary line and any other log output go to stderr. It works on
  the static and Postgres engines.

  Until now the only ways to get a ChangeSet were an agent's `write_content`
  result or calling `compile()` from `@usegraft/compiler`. The new output is the
  body a revalidate route reads, so a deploy script can pipe it on:

  ```sh
  graft compile --json | curl --fail-with-body -X POST -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" -H "content-type: application/json" --data-binary @- https://example.com/api/revalidate
  ```

  `--fail-with-body` makes curl exit non-zero on an error response while still
  printing its message and fix. It needs curl 7.76.0 or newer. On an older curl,
  use `--fail`, which fails the same way but drops the body.

  postgres-js prints server notices with `console.log`, so while a `--json`
  compile runs, `console.log` is routed to stderr. Nothing a dependency logs can
  corrupt the JSON.

  `cli` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c)

- `graft serve` can check the token audience and record trusted callers as people.

  `GRAFT_TRUSTED_ISSUERS` built each issuer with only its URL. The token audience
  was never checked, so a token the provider minted for another of your apps was
  accepted, and every caller was recorded in the audit log as an agent.

  Two variables now apply to every listed issuer:

  - `GRAFT_TRUSTED_AUDIENCE`: the accepted `aud` values, comma-separated. When
    issuers are set without it, `graft serve` prints a warning at startup.
  - `GRAFT_TRUSTED_ACTOR_KIND`: `agent` (the default) or `human`. Any other value
    stops the server from starting with `INPUT_VALIDATION_FAILED`, before it opens
    a database connection.

  `cli` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957)

- Concurrent `graft migrate --apply` runs apply each migration once.

  The ledger was read before a run and written after it, so two runs started
  together both saw a migration as pending and both applied it. The second then
  failed on the ledger's unique index, after its content rewrite and data updates
  had already run.

  `@usegraft/db` exports `withMigrationLock(db, branchId, work)`. It holds a
  transaction-scoped Postgres advisory lock for the branch while `work` runs.
  `graft migrate --apply` reads the ledger only once it holds the lock, so a
  second run waits for the first, then skips what the first applied.
  `graft merge --apply` takes the same lock on both branches, in a fixed order:
  it writes the target's ledger, and reads the source's, so no migration can land
  on the source while its rows move.

  The lock is transaction-scoped so it works behind a transaction-mode pooler
  such as Neon's PgBouncer. A crashed run drops its connection, which ends the
  transaction and frees the lock. There is no stale claim to clear.

  The lock's transaction pings every 10 seconds while the run works, so an
  idle-in-transaction timeout longer than that does not end it early. It needs a
  pool of at least two connections, one for the lock and one for the work, and
  refuses a smaller pool up front instead of waiting on itself.

  `cli`, `db` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957)

### Fixes

- `graft <command> --help` prints that command's usage and exits 0.

  Before, `--help` or `-h` after a command name failed with
  `unknown option "--help"`. Every command now has its own usage block: the
  usage line, what it does, and only the flags it reads. Help is answered before
  the other arguments are parsed, so `graft merge --into --help` still prints the
  merge usage. An unknown command with `--help` still reports the unknown command.

  `cli` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957)

- Clearer wording for two error explanations. `NEEDS_DATABASE` now says the project runs on the static storage engine and the feature needs the Postgres engine, instead of "static index mode" and "Postgres-tier". The registry entry now says "core registry" instead of "Tier-1 registry". `@usegraft/tokens` adds a `--measure` token for the reading width of prose. It also defines `--space-5`, which eight declarations already used while it was undefined, so callouts and other spaced elements lost their padding. Callouts get their own note and warning hues instead of reusing the accent, and `--n-10` names pure white for text on the primary color in dark mode.

  `mcp`, `tokens` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c)

- `run_function` now sets `openWorldHint: true`. It runs the project's own
  functions, and a function can call any external service, so it cannot promise
  the closed world every other tool does. Every other tool keeps
  `openWorldHint: false`.

  `mcp` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c)

- Fix the caching note in each adapter's JSDoc. It said to purge
  `tagsForChanges(branch, changeSet)` "from your compile webhook", but Graft sends
  no webhook. It now says to purge from your own route, called with a compile's
  change list, for example the output of `graft compile --json`.

  `sdk-astro`, `sdk-react-router`, `sdk-sveltekit`, `sdk-tanstack-start` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c)

- Keep every cache tag within 256 characters. Next.js skips a longer tag with a
  warning instead of an error, and a skipped tag is never invalidated, so a page
  read under a long slug stayed stale after its content changed.
  `documentTag` and `collectionTag` now return a tag longer than 256 characters
  as its first part plus a digest of the whole. Reads and `tagsForChanges` build
  tags the same way, so they still match. Tags of 256 characters or fewer are
  unchanged.

  `sdk-core` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c)

- `useContentSearch` and `graft.searchContent` now treat an empty or
  whitespace-only query as a search with no results. They return `[]` without
  sending a request, so a search box that starts empty no longer shows an error
  before anyone types.

  All three hooks also stop showing the previous answer for one render when
  their arguments change. `useContent` with a new slug, or `useContentSearch`
  with a new query, now reports loading in that same render instead of briefly
  rendering the old document or hits.

  `sdk-react` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c)

## 1.0.0-beta.2

Released September 7, 2026.

### Fixes

- Clear leftover CodeQL findings: no ReDoS regex on storage URLs, no exception text on `/healthz` or Studio asset-url failures, and `graft studio` only opens a validated loopback URL.

  `assets`, `cli`, `studio` · [98ff4ba](https://github.com/AndersonDesign1/graft/commit/98ff4ba)

## 1.0.0-beta.1

Released September 2, 2026.

### Fixes

- Install commands drop `@beta`. A plain install is now the right install.

  Every README said `npm i @usegraft/<pkg>@beta`, because `latest` pointed at
  `0.2.0` while the docs described `1.0.0-beta.x`. Writing the tag into 22 files
  treated the symptom. The defect was the dist-tag: `latest` is what a bare
  install resolves, and it resolved to somewhere nobody should land.

  `latest` now points at the prerelease across all 21 published packages, and the
  `0.x` line is deprecated, so the tag has nothing left to do. `install-tag.mjs`
  inverts with it — it strips tags instead of adding them, and CI fails if one
  comes back.

  The half a script cannot check is the registry. Publishing a prerelease while
  `latest` sits on something older reopens the original bug and nothing in the
  repo will notice. That property is kept by moving the tag at release, and it is
  written down in the script rather than assumed.

  `assets`, `auth`, `cli`, `compiler`, `content-api`, `content-migrations`, `contracts`, `core`, `db`, `mcp`, `mdx-safety`, `registry`, `sdk-astro`, `sdk-core`, `sdk-next`, `sdk-react`, `sdk-react-router`, `sdk-sveltekit`, `sdk-tanstack-start`, `studio`, `tokens` · [52fc3e6](https://github.com/AndersonDesign1/graft/commit/52fc3e6)

- Dependency floors raised to versions without known advisories.

  `main` was carrying 34 Dependabot alerts, and a range-respecting update across
  the workspace clears 23 of them: `next` 16.2.9 → 16.3.3 (nine alerts on its
  own), `hono` 4.12.27 → 4.13.5, `postcss` 8.4.31 → 8.5.23, plus `astro`, `tar`,
  `sharp`, `svgo`, `@astrojs/vercel` and `@hono/node-server`.

  Declared ranges move with the lockfile, which is what makes this a change worth
  a version rather than a lockfile refresh: `zod` `^4.1.0` → `^4.5.4`, `jose`
  `^6.0.0` → `^6.2.10`, `jiti` `^2.4.0` → `^2.7.0` and the React type packages.
  Every one is a floor raised inside the same major. **No peer range changed** —
  `sdk-next` still peers `next >=15.0.0`, `sdk-react` still peers `react >=18.0.0`
  — so nothing a consumer already resolves stops resolving.

  Eleven alerts survive this and are deliberately not addressed here. Two are
  `vitest`, pinned at `^2.1.9` against a fix in `3.2.6`: a major upgrade of the
  test framework is its own change, not a line in a dependency bump. The other
  nine are transitive versions their parents pin — `esbuild` now resolves
  `0.28.1` and `0.28.2` alongside `0.18.20`, `0.21.5`, `0.25.12` and `0.27.7`,
  and only `pnpm.overrides` dislodges those. Each override is a compatibility bet
  and belongs where it can be argued one at a time.

  `auth`, `cli`, `contracts`, `core`, `mcp`, `registry`, `sdk-next`, `sdk-react`, `studio` · [27b8468](https://github.com/AndersonDesign1/graft/commit/27b8468)

- Point the getting-started install at `@beta`, because the docs already describe it.

  `latest` is `0.2.0` and these docs are written against `1.0.0-beta.x`. That gap
  is not cosmetic: `approvalPolicy` moved out of `GRAFT_APPROVAL_POLICY` and into
  `graft.config.ts`, so someone following the page on a default install configures
  a policy their build ignores. The relative-endpoint fix and the database leaving
  the browser dependency graph are also beta-only.

  Every install command on the page now carries `@beta`, with a note saying it is
  a channel rather than a snapshot — subscribe once, upgrade normally, drop the
  tag when 1.0 ships.

  `cli` · [1f23844](https://github.com/AndersonDesign1/graft/commit/1f23844)

- Move the docs site to the static tier. It needs no database now.

  The docs are MDX in git. The only things forcing Postgres were a `submissions`
  collection and two functions — `docStats` and `submitContact` — that **no page
  in `src/` referenced**. They were declared to exercise the Postgres tier and
  never rendered, while the landing page carries that demo properly: its own
  `submitContact`, wired to a `<ContactForm />` a visitor can actually post.

  So documentation was paying a database's availability, cost and cold starts to
  demonstrate something it did not show. `graft compile` writes `.graft/index.db`
  and 36 pages prerender from it.

  `/mcp` still serves agents — the docs MCP handler takes `staticIndexPath`, so it
  reads the same artifact. `/api/search` stays on-demand for the same reason. The
  two routes that genuinely needed Postgres, `/api/fn/[name]` and the
  authenticated `/api/mcp`, are gone; both were already local-only, the latter by
  its own comment ("never set it on a deployed instance").

  The build is `graft compile && astro build`, and the Vercel adapter is told to
  include the artifact — Vercel traces imports, not data files, so the on-demand
  routes would otherwise deploy without the thing they read.

  `cli` · [db5abe6](https://github.com/AndersonDesign1/graft/commit/db5abe6)

- Mark a grouped release as a prerelease when the version has one.

  `v1.0.0-beta.0` was created unmarked, which leaves GitHub free to show it as the
  repository's Latest release — telling every visitor the beta is the current
  version, the exact confusion a beta channel exists to prevent. changesets marks
  its per-package releases; the grouped one now does too.

  `cli` · [411e9f0](https://github.com/AndersonDesign1/graft/commit/411e9f0)

- Keep every documented install command on the channel we actually ship.

  While the repo is in beta, `latest` is an older version than the docs describe.
  A README saying `npm i @usegraft/cli` sends a reader to a build that does not
  match the page they read it from — during this beta that means `approvalPolicy`
  in `graft.config.ts` silently doing nothing, because `0.2.0` still reads an
  environment variable.

  `scripts/install-tag.mjs` derives the tag from `.changeset/pre.json` instead of
  anyone remembering: pre mode means `@beta`, stable means no tag.
  `release:beta-enter` and `release:beta-exit` both run it, so entering and
  leaving the channel rewrites 22 READMEs and the docs with it, in both
  directions. CI checks it, because the failure is silent otherwise.

  CHANGELOGs are deliberately untouched. They narrate what happened at a version —
  "`npm i @usegraft/sdk-react` installed postgres and drizzle-orm" is a statement
  about the past, and rewriting it would falsify the record.

  `cli` · [0e537ee](https://github.com/AndersonDesign1/graft/commit/0e537ee)

- `npx @usegraft/cli init` in the README could run a copy from weeks ago.

  `npx`, `pnpm dlx` and `bunx` fetch a package, run it, discard the process — and
  keep the tarball. So a bare runner command re-runs whatever it cached last,
  without saying so. That is the failure the dist-tag work was meant to close,
  arriving by another road: the registry is right and the machine is stale.

  `@latest` is what tells them to check, and it is why `npx create-next-app@latest`
  is written that way everywhere. The reason is the cache, not decoration.

  `npm i -D @usegraft/cli` stays bare. A bare install already resolves `latest` —
  that is what it means — so the tag would add a word that changes nothing, and a
  tag that is load-bearing on some lines and noise on others teaches a reader to
  skim past tags where it matters. `scripts/install-tag.mjs` now enforces both
  halves: `@latest` on the ephemeral three, absent on the installers.

  `cli` · [94c8909](https://github.com/AndersonDesign1/graft/commit/94c8909)

## 1.0.0-beta.0

Released September 1, 2026.

### Breaking changes

- **BREAKING:** approval policy moves from `GRAFT_APPROVAL_POLICY` to
  `approvalPolicy` in `graft.config.ts`. The env var is ignored, and `graft serve`
  warns once at boot if it is still set.

  `createFunctionsHandler` has documented this setting as config-owned all along:
  "deliberately a value the operator writes in config rather than an env var,
  because turning off the gate on irreversible work should appear in a diff and a
  review." The CLI was the piece still reading an env var, so the rationale was
  written down and not enforced. This is the setting that lets `deleteRecord`
  hard-delete rows with no human in the loop, and a hosting dashboard is where
  that decision goes unreviewed. It is parsed like `mdxTrust`: an unknown value is
  refused rather than defaulted, so a typo cannot silently pick a weaker policy.

  ```ts
  // graft.config.ts
  export const approvalPolicy = "unattended";
  ```

  **An approval presented to an ungated call is now spent.** Under `"unattended"`
  the gate is skipped, and the whole block went with it, including
  `approvals.consume`. A granted row stayed `approved` and replayable: tighten the
  policy later and that row still authorized a destructive call nobody
  re-reviewed. One-shot has to survive a policy change, which is exactly when the
  stale row is dangerous. Consuming is best-effort here, because an ungated call
  must not fail on the approval store.

  **`run_function` over MCP gets the same rate-limit backstop as `POST /api/fn`.**
  `graft serve` passed `{ limit: 60, windowSeconds: 60 }` to the functions handler
  and nothing to the MCP handler, so a function with no per-function `rateLimit`
  was capped on one transport and uncapped on the other. `tools/functions.ts`
  claims both surfaces apply rate limits identically; now they do.

  **`assertSafeMdx` reports unparseable MDX as `INPUT_VALIDATION_FAILED`.**
  `UncheckableMdxError` escaped it raw, so `write_content` and a Studio save
  returned a bare `Error` where every other rejection on that path is a structured
  `GraftError` — a client could not tell malformed input from a transport fault.
  `graft compile` catches the raw error itself and is unaffected.

  All four found by cubic on the pull request.

  `cli`, `core`, `mdx-safety` · [2561b47](https://github.com/AndersonDesign1/graft/commit/2561b47)

- **BREAKING:** `writeDocumentFile(root, sourcePath, raw)` replaces
  `writeDocumentFile(fullPath, raw)`, and contains the path itself. It returns
  the resolved path.

  `.greptile/rules.md` told reviewers that "every filesystem sink in
  `@usegraft/compiler` performs path containment with symlink refusal". That was
  false. `writeDocumentFile` took an already-resolved path and wrote it. Studio
  resolved carefully before calling — its source even carries a comment about
  `"../../../../tmp/pwn"` arriving as a clean-looking `"pwn"` — while MCP's
  `write_content` did not, which is how a traversal shipped in a package whose
  own review rules said it could not.

  Containment that lives in each caller is not an invariant, because the next
  caller does not inherit it. The sink no longer trusts its input, so the rule is
  now true rather than aspirational. The rule text was corrected too, and says
  what went wrong, since a rule that has been false once is worth annotating.

  Raised by cubic on the pull request, against the rules file rather than the
  code.

  `compiler`, `mcp`, `studio` · [a6b7ddf](https://github.com/AndersonDesign1/graft/commit/a6b7ddf)

- **BREAKING:** `createClient` takes `index` and no longer takes `db`. Pass a
  Postgres handle to `createDbClient` from the new `@usegraft/sdk-core/db` entry
  point instead. Every framework adapter's `createGraft` still takes either shape,
  so app code that used an adapter is unaffected.

  `npm i @usegraft/sdk-react` installed `postgres` and `drizzle-orm`. cubic
  flagged it on the pull request and it was worse than a stray manifest entry:
  `sdk-core/src/client.ts` imported `createDbIndexReader` from `@usegraft/db` for
  its **value**, so the database package was reachable code, not a tree-shakeable
  type import — in a package whose stated premise is that a database handle never
  reaches the browser. The README and `/docs/sdk-reference` both made that claim
  while the dependency graph contradicted it.

  The runtime edge now lives in `@usegraft/sdk-core/db`, and `@usegraft/db` is an
  **optional peer dependency** of `sdk-core`. A server adapter declares it
  outright and resolves it; a browser install never pulls it. The five server
  adapters gained it as a direct dependency, which is honest — they always did
  need it.

  **The type edge had to move too, or the fix would only have been half true.**
  `ClientOptions` referenced `Database`, and `ContentRow` was
  `typeof contentIndex.$inferSelect` — derived from a Drizzle table — so any
  package that merely wanted to _describe_ a row had to install a database driver
  to name the type. `ContentRow`, `ContentIndexReader`, `ReaderReadOptions`,
  `ReaderSearchOptions`, `ContentSearchHit` and `ChangeSet` now live in
  `@usegraft/contracts`, the layer every package already shares.

  `@usegraft/db` re-exports all six, so existing imports keep resolving, and its
  table now _proves_ it still matches the published contract instead of defining
  it — a compile-time assignment that fails if a column changes type. The
  dependency runs the right way round now: the seam owns the shape and the
  implementation conforms to it.

  `@usegraft/content-api` drops `@usegraft/db` entirely; its imports were always
  type-only.

  Two smaller fixes fall out of the same review. `@usegraft/sdk-react` now refuses
  a **per-read** `branch` on an endpoint-backed handle, not just one passed to the
  constructor: the content API pins its branch server-side and rejects the query
  param, so `getContent(c, s, { branch })` silently read main while the caller
  believed they were reading a preview. And the three read helpers are `async`, so
  that refusal arrives as a rejection rather than a synchronous throw from a
  function declared to return a promise — the kind a caller handling errors with
  `.catch()` never sees. An index-backed handle is unaffected, which is tested,
  because that is the one configuration where a branch is meaningful.

  `content-api`, `contracts`, `db`, `sdk-astro`, `sdk-core`, `sdk-next`, `sdk-react`, `sdk-react-router`, `sdk-sveltekit`, `sdk-tanstack-start` · [e2829b4](https://github.com/AndersonDesign1/graft/commit/e2829b4)

- **BREAKING:** `@usegraft/db` is an optional peer dependency of
  `@usegraft/core` rather than a dependency.

  `db-out-of-the-browser` moved the database off sdk-core's direct dependencies,
  but only half the graph went with it. `@usegraft/core` stayed a hard dependency
  of sdk-core and kept its own hard dependency on `@usegraft/db`, so the chain
  survived one hop further out:

      sdk-react -> sdk-core -> core -> db -> postgres, drizzle-orm

  Bundling was already safe, because sdk-core imports core with `import type` and
  those erase. The install was not: `npm i @usegraft/sdk-react` still downloaded
  `postgres` and `drizzle-orm`, which is the exact complaint that fix opens with.
  cubic kept the thread open on the pull request and was right to.

  `npm i @usegraft/sdk-react` now pulls one external package, `zod`.

  Every package that reaches core's database-backed modules at runtime already
  declares `@usegraft/db` directly (`cli`, `compiler`, `mcp`, `studio`, and the
  five server adapters), so nothing needs a new dependency. The packages that
  depend on core without it — `auth`, `content-migrations`, `sdk-react` — import
  only types. If you depend on `@usegraft/core` directly and call
  `defineDataMigration`, `records`, or the functions handler, add
  `@usegraft/db` to your own dependencies.

  `core`, `sdk-core`, `sdk-react` · [655e4d1](https://github.com/AndersonDesign1/graft/commit/655e4d1)

### Features

- Let the content API be read from a browser on another origin.

  `@usegraft/sdk-react` reads over HTTP from a browser, and the handler sent no
  CORS headers at all — so unless the app and the content API shared an origin,
  every read was blocked by the browser. That is the ordinary deployment for the
  browser client, which made it largely unusable as shipped. cubic raised it on
  the pull request.

  `createContentApiHandler` takes `allowedOrigins`: a list, or `"*"`. Omitted
  means no CORS headers and same-origin only, which stays the default because
  publishing an endpoint to other origins is the deployer's decision rather than
  a library's. `graft serve` reads `GRAFT_CONTENT_ALLOWED_ORIGINS` (comma
  separated, or `*`).

  Details that are easy to get wrong and are tested:

  - An allowed origin is echoed with `Vary: Origin`. Without `Vary`, a shared
    cache can hand one origin the response it stored for another and the
    allowlist stops meaning anything.
  - A disallowed origin gets an ordinary response with no CORS header, not a
    refusal. The browser enforces it; answering differently would turn the
    allowlist into an origin oracle for non-browser callers, who are not bound by
    CORS in the first place.
  - `OPTIONS` preflight is answered before method validation and before the rate
    limiter, so a browser asking permission is neither a `405` nor a charge
    against the budget for the read it precedes.
  - Error responses carry the headers too, or the browser hides the body and a
    developer debugging a `400` sees an opaque network failure instead of the
    `fix` this API took care to send.

  `cli`, `content-api` · [d5f5567](https://github.com/AndersonDesign1/graft/commit/d5f5567)

- Add `list_packages`, so an agent can answer "what do I install?"

  The tool surface could already introspect a project that exists — its
  collections, functions, errors, owned primitives. Nothing said which
  `@usegraft/*` package to reach for, so a user on SvelteKit could only be told
  about `@usegraft/sdk-sveltekit` if the model happened to have read the docs.
  That is the first question anyone asks, and it was the one question with no
  surface.

  Filter by `framework` and you get that adapter plus the packages every project
  needs, never a competing one. Filter by `tier` and a static project is never
  told to install something that cannot work without Postgres. It is registered
  on the public documentation mount too: it carries nothing about the project,
  only which of Graft's own packages exists, which is documentation in the
  plainest sense.

  `PACKAGE_KNOWLEDGE` is held in lockstep with what actually ships by a test, the
  same way `ERROR_KNOWLEDGE` is with `ErrorCodes` — a package added without an
  entry is a package no agent will ever suggest. It caught `@usegraft/tokens`
  missing on its first run.

  `cli`, `mcp` · [76baf51](https://github.com/AndersonDesign1/graft/commit/76baf51)

- Let `endpoint` be a same-origin path, which is what the docs already tell you
  to write.

  `createGraft({ endpoint: "/api/content/v1" })` threw `TypeError: Invalid URL`
  before a single read. `normalizeEndpoint` called bare `new URL(endpoint)` with
  no base, and a relative path has no origin to parse against.

  That form is not a stray idea — it is the example in
  `/docs/reading-content` and in the `createGraft` JSDoc that ships inside
  `@usegraft/sdk-react`. So the first thing a browser reader copied out of the
  documentation crashed at construction. cubic raised it on the pull request.

  A string endpoint now resolves against `location.href` when one exists. An
  absolute endpoint is unchanged, and a `URL` instance is unchanged. Outside a
  browser there is no origin to resolve against, and guessing one would send
  content reads somewhere arbitrary, so a relative path there is refused as
  `CONFIG_INVALID` with that reason rather than a bare `TypeError`.

  `content-api`, `sdk-react` · [655e4d1](https://github.com/AndersonDesign1/graft/commit/655e4d1)

### Fixes

- Rate-limit the content API, and give `graft serve` the same backstop there it
  already gave functions.

  `graft serve` passed `rateLimit: { limit: 60, windowSeconds: 60 }` to
  `createFunctionsHandler` and nothing to `createContentApiHandler`, which had no
  such option to pass. Greptile demonstrated the consequence on the pull request
  by running it: 61 requests to `/api/content/v1/documents`, 61 responses of
  `200`, no `Retry-After` on the last one. These routes authenticate nobody and
  run database listings and full-text searches, so one anonymous caller could
  keep the index busy for as long as it liked.

  `createContentApiHandler` now takes `rateLimit` and `trustedProxyHops`.
  Omitting `rateLimit` means unlimited, which stays correct for a handler mounted
  behind something that already limits. `graft serve` passes the same 60 per 60
  seconds it gives functions.

  The counter is in memory rather than in the audit table. The function limiter
  counts rows it is already writing; these are reads that write nothing, and
  adding a write per read to enforce a read limit inverts the cost of the thing
  being protected. Two honest consequences, both documented: it is per process,
  so N replicas allow N times the limit and a restart resets it; and it is a
  fixed window where functions get a rolling one. A deployment needing an exact
  global limit puts it in the proxy already terminating TLS.

  The check runs before the index is touched, and after method and route
  validation. Both halves are tested: a limit that fires once the query has
  already run protects nothing, and charging a `405` to the bucket would let
  someone probing with the wrong method spend the shared anonymous budget.

  **The caller-identity rule is now shared rather than copied.** `rateIdentity`
  and the peer registry move from `@usegraft/core` to `@usegraft/contracts`,
  which both packages already depend on, and `@usegraft/core` re-exports them so
  `setRequestPeer` keeps resolving where callers already import it. That rule —
  never read `x-forwarded-for` unless the deployment declares its proxy depth,
  then count from the right — is listed in `.greptile/rules.md` as a security
  invariant precisely because the obvious implementation is the wrong one, and
  two copies of it is how a future fix lands in only one.

  Also documents what was true before this change and unwritten: the content API
  performs no authentication at all, so its `collections` list is a security
  boundary rather than a convenience, and `graft serve` registers every
  collection in the config.

  `cli`, `content-api`, `contracts`, `core` · [15568eb](https://github.com/AndersonDesign1/graft/commit/15568eb)

- Add a versioned, read-only authored-content HTTP API and a remote
  `ContentIndexReader`. `graft serve` now mounts document reads and search at
  `/api/content/v1`, fixed to the server's resolved branch.

  `cli`, `content-api`, `mcp` · [1139a88](https://github.com/AndersonDesign1/graft/commit/1139a88)

- Let a local MCP server ask its operator to decide a destructive call in-band,
  instead of failing with an id for them to run `graft approve` on.

  Opt-in and off by default: `approvalElicitation: { decider }` on
  `createGraftMcp`, or `graft mcp --elicit-approvals`, which records the decision
  as the OS user — exactly who `graft approve` records. A remote or public mount
  must never enable it: there is nobody at the other end to ask.

  What changes is how the human is reached. What does not change is anything
  underneath: the decision is still a row in `approvals`, still one-shot, still
  bound to the exact function and canonical input, still stamped with
  `decided_role = current_user`, and still refused by Postgres when the decider is
  the requester. `decider` is configured rather than taken from the connection
  precisely because that predicate lives in the UPDATE's own `WHERE`.

  A client that never declared the elicitation capability falls back to the
  id-and-retry flow. Dismissing the prompt leaves the approval pending — only an
  explicit no records a denial.

  `delete_content`, `run_function` and the DESTRUCTIVE_OP_REQUIRES_APPROVAL
  explanation all now say the in-band path exists, so an agent seeing either
  outcome knows both are ordinary.

  `cli`, `mcp` · [d3c51d1](https://github.com/AndersonDesign1/graft/commit/d3c51d1)

- Add a real release channel switch, and one GitHub Release per version.

  Canary was doing a job it cannot do. A snapshot is `0.0.0-canary-<timestamp>`
  forever: it sorts _below_ every real release, npm never offers it as an
  upgrade, and there is no path from it to a stable version — you hand someone a
  build and they pin a timestamp. It answers "does this commit work?", not "is the
  next version ready?"

  Beta is changesets prerelease mode: `1.0.0-beta.0` → `1.0.0-beta.1` → `1.0.0`.
  A tester runs `npm i @usegraft/cli@beta` once and gets each new beta as an
  ordinary upgrade, while `latest` stays where it is.

  ```sh
  pnpm release:beta-enter   # commit .changeset/pre.json
  pnpm release:beta-exit    # graduate to stable
  pnpm release:channel      # which channel am I on?
  ```

  release.yml's own comment named the reason pre mode was avoided: the state is
  committed, feat/core takes every ordinary push, and nothing would remind you to
  leave. So the release job now prints the active channel before anything
  publishes, and the canary path refuses to run in pre mode and says why. Being
  in beta is something the log tells you, not something you remember.

  **One release per version.** `fixed` makes all 21 packages one product on one
  version line, but changesets/action tags and releases each separately — 21 tags
  and 21 GitHub Releases per version, with one arbitrarily flagged "Latest"
  because GitHub picks by recency. Tags fragmented the history instead of
  grouping it. A `v<version>` release now collects every package's changelog
  entry. The per-package tags stay, because npm and provenance point at them.

  `cli` · [76baf51](https://github.com/AndersonDesign1/graft/commit/76baf51)

- CI asserts that `.github/rulesets/*.json` still matches the branch protection
  GitHub is actually enforcing.

  Those files are a record, not a mechanism — a rule relaxed in the web UI leaves
  no diff anywhere, which is why the directory exists and also its weakness. The
  README carried "checked 2026-08-31", a claim with a shelf life. `pnpm
check:ruleset-drift` turns it into a job.

  It is deliberately one-directional: it never applies the file, because a commit
  that could rewrite branch protection is a commit that could remove it. The
  record can fail the build without being able to weaken the branch.

  Reading bypass actors needs `administration: read`, which the built-in
  `GITHUB_TOKEN` cannot hold — that permission is not one a workflow may request
  for it. The job reads a `RULESETS_TOKEN` secret (a fine-grained PAT scoped to
  this repository, `Administration: Read-only`). Until it exists the job skips and
  says why rather than passing: without `bypass_actors` GitHub answers `200` with
  `rules` alone, and checking the rules while never checking who can ignore them
  prints a green tick nobody should trust. A fork's pull request sees the same
  skip, so outside contributors are not blocked. It runs as its own job rather
  than inside `verify`, keeping that token away from the runner that executes
  every installed dependency.

  `cli` · [4ac881c](https://github.com/AndersonDesign1/graft/commit/4ac881c)

- Fix the install command on every package page. `npx graft` resolves to an
  unrelated package on npm, so the documented way to run the CLI without
  installing fetched the wrong thing. It is `npx @usegraft/cli` everywhere now,
  with a note saying why the scoped name is needed.

  Fix the `graftRoute` example in `@usegraft/sdk-astro`. It showed a config
  object, but `graftRoute` takes the handler, so the snippet did not compile.

  Document the static index in the SDK READMEs. `createClient` and `createGraft`
  both accept `index` from `openStaticIndex(".graft/index.db")`, which is what
  `graft init` scaffolds by default, and none of the READMEs mentioned it. Every
  read example now shows where its `db` or `index` comes from instead of leaving
  the handle undefined.

  `@usegraft/sdk-sveltekit` gets the same treatment, and its README ships in this
  release. It was `private` and so could not be named in a changeset at all. It
  is public now, at the same version as the rest of the workspace.

  `cli`, `sdk-astro`, `sdk-core`, `sdk-next` · [36d6045](https://github.com/AndersonDesign1/graft/commit/36d6045)

- Publish `@usegraft/sdk-sveltekit`. It sat at `0.1.0` and `private` while every
  other package in the workspace was at `0.2.0`, and `private` is what kept it
  there. The release config puts every `@usegraft/*` package in one `fixed`
  group, and changesets counts a private package as ignored, so naming this one
  beside any other failed the mixed-changeset check and the release plan refused
  to build. That left no way to ship a fix for it at all, and it fell one version
  further behind on each release. It is public at `0.2.0` now and versions with
  everything else.

  Nothing in the package itself changed. The README, the adapter and its tests
  were already written and are untouched. `src/` still tracks
  `@usegraft/sdk-astro`: the same `createGraft` and `graftRoute`, differing only
  in doc comments, the `context` to `event` rename that SvelteKit's
  `RequestEvent` asks for, and the URL one test uses as a fixture. That adapter
  is exercised by `examples/docs-site`, so this code path has been covered all
  along and only the packaging held it back.

  Pin the MinIO images in the self-host Dockerfile to digests. `minio/minio` and
  `minio/mc` were both on `latest`, which MinIO moves on every release, so the
  image the README tells people to `docker run` could change from one build to
  the next while nothing in this repo did, and a bad upstream release would
  arrive with no way to tell what had shifted. Every GitHub Action here is
  already SHA-pinned for that reason.

  `cli`, `sdk-sveltekit` · [3bb7f7b](https://github.com/AndersonDesign1/graft/commit/3bb7f7b)

- Add `approvalPolicy: "unattended"`, so a caller with no human behind it can run
  a destructive function.

  `"none"` and `"human"` both had no answer for a scheduled job or a CI
  migration. The destructive arm of the gate had no off switch at all, which is
  the absence of a policy rather than a policy: those callers could never invoke
  a destructive function, ever.

  `"unattended"` turns the gate off entirely. Everything else is unchanged —
  every invocation still writes its audit row with actor, correlation id and git
  SHA, and access rules and rate limits still apply. What is given up is the
  waiting, not the accounting.

  It is deliberately **not on the MCP surface**. `GraftMcpOptions` accepts only
  `"none"` and `"human"`, so there is no server setting that makes `run_function`
  stop asking before a destructive call. The policy exists for a caller with no
  human behind it; an MCP mount exists because an agent is calling it, and the
  agent is precisely the party the gate is there to stop. On `graft serve` that
  split is observable — the env var lifts the gate on `POST /api/fn` and leaves
  `POST /api/mcp` gated.

  `graft serve` reads it from `GRAFT_APPROVAL_POLICY=unattended` and warns on
  every boot while it is on, because an env var is one line in a dashboard and
  the log is where a mistake gets noticed.

  Worth being explicit about the trade: git restores authored content, so a
  deleted document comes back, but it does not restore operational data.
  `deleteRecord` removes rows outright and the asset store keeps no history.

  `cli`, `core`, `mcp` · [a442299](https://github.com/AndersonDesign1/graft/commit/a442299)

- Refuse elicited approvals over HTTP, and stop a failed prompt breaking the call.

  Three findings from cubic's review of the pull request, all on
  `approvalElicitation`.

  **`createGraftMcpHandler` now throws `CONFIG_INVALID` if given
  `approvalElicitation`.** It was documented as "a remote or public mount must
  never set it" and enforced nowhere. A documented caution is the wrong shape for
  this one, because getting it wrong inverts the gate rather than weakening it:
  over HTTP the client being asked to approve _is_ the agent that made the call,
  while `decider` is configured server-side — so an accepted prompt is
  self-approval recorded under the operator's name. `requested_by_id <>
decided_by` lives in the UPDATE's own `WHERE` precisely so that cannot happen,
  and asking the requester's own client walks around it. `createGraftMcp` (stdio,
  operator at the machine) still accepts it.

  **A failed elicitation falls back instead of failing the tool call.** The
  capability check covered a client that never declared it; a client that declares
  it and then fails the request — older SDK, a schema it will not render, a
  transport that times out with the dialog open — let the error escape as a raw
  `McpError`. It now falls through to the id-and-retry path, the same answer an
  undeclared capability gets. Nothing is decided, so the row stays pending for a
  human to find.

  **The prompt no longer truncates silently.** Inputs were cut at 300 characters
  with an ellipsis; the limit is now 1000 and the message says it truncated, gives
  the full length, and points at `graft approvals`. A consent dialog that shows
  part of what is being consented to without saying so is worse than no preview,
  because the reader believes they have seen the call.

  `mcp` · [18ac061](https://github.com/AndersonDesign1/graft/commit/18ac061)

- Add `createDocsMcp` / `createDocsMcpHandler`: a public, read-only documentation
  MCP server, for `/mcp` on a docs domain.

  It follows what docs platforms already publish. Mintlify generates a docs MCP at
  `/mcp` for every site it hosts — public, unauthenticated, strictly read-only,
  offering search plus navigating and reading the docs filesystem. Cloudflare runs
  a documentation server separately from its authenticated API server. Agents
  arrive expecting this shape.

  The surface is `list_collections`, `list_content`, `get_content`,
  `search_content`, `explain_error`, and the document resources. What is missing
  is mostly not about writes: `describe_schema` carries the project's functions,
  `list_registry` its owned primitives, and the branch, compilation and approval
  listings its operations. Those are all reads, which is why "read-only" is the
  wrong test for what belongs on a public endpoint.

  It is a separate factory rather than a flag on `createGraftMcp`, so the
  authenticated endpoint gains no new way to be opened. `DocsMcpOptions` omits
  `functions`, `actor`, storage and approval elicitation outright, and
  `createDocsMcpHandler` has no `allowAnonymous` escape because it has nothing to
  authenticate.

  Internally this splits the content tools into read and write registrations, the
  introspection tools into collection and function halves, and the resources into
  documents and schema. A mount whose whole purpose is public should not be one
  `if` away from `write_content`.

  `mcp` · [ffd5a07](https://github.com/AndersonDesign1/graft/commit/ffd5a07)

- Declare `outputSchema` on the 16 tools whose answer has a fixed shape, so
  `structuredContent` is a validated contract rather than a convenience.

  The SDK checks every result against the schema before it leaves the server, so
  a tool that quietly changes shape now fails at its own boundary instead of in
  whatever the agent tried to do with the answer. Where a contract already existed
  in `@usegraft/contracts` it is reused rather than restated: `describe_schema`,
  `describe_function` and `describe_item` were already published shapes with a
  drift test, and this attaches them to the wire.

  `run_function` and `delete_content` deliberately keep no schema. Both return
  whatever the project's own function returns, so a declared shape would promise
  nothing and cost a validation pass. They are an explicit exemption in the test
  rather than an omission that looks like an oversight.

  `mcp` · [e36c09b](https://github.com/AndersonDesign1/graft/commit/e36c09b)

- **Security:** `write_content` could write outside `contentDir`.

  The `slug` argument was `z.string()` with no shape constraint, and the path was
  built as `join(contentDir, collection, slug + ".mdx")`. `parseDocument`, which
  runs afterwards, is not a guard against this: it validates
  `basename(sourcePath)`, so a slug of `"../../escaped"` parses as the entirely
  legal slug `"escaped"` while the join walks two directories up. Confirmed by
  running it before the fix — the file landed outside the content tree and the
  call returned success, not an error.

  Any caller holding `content:write` could therefore write an arbitrary file
  anywhere the server process could. That is most of the point of the scope on a
  local stdio server, and not at all the point on an HTTP mount.

  Two independent defences, each verified to hold on its own:

  - `assertSlugShape` rejects a non-kebab-case slug in the handler with
    `INVALID_SLUG` and a `fix`. Deliberately not a `.regex()` on the input
    schema — zod rejects before the tool body runs and the SDK surfaces that as a
    bare protocol error with no `fix`, which is the self-teaching an agent most
    needs when the correct slug is one edit away.
  - `resolveContained` decides where bytes actually land, so the guarantee does
    not rest on the check above still being there.

  `delete_content` gets the same slug check, ahead of `findDoc` and ahead of the
  approval, so a malformed slug never reaches a human as a pending decision.

  The **document resource read** and the **delete** now resolve through
  `resolveContained` too. Those follow a `sourcePath` produced by a directory
  scan, which a caller cannot inject — but the scan lists a symlink like any
  other entry, and following one would serve or unlink a file outside the content
  tree. That matters most on the read, because the read-only documentation server
  is an unauthenticated mount. Raised by cubic on the pull request; the write
  traversal above was found by pulling on it.

  `mcp` · [04f7576](https://github.com/AndersonDesign1/graft/commit/04f7576)

- Add prompts and argument completion.

  Four prompts, each filled in from the live project: `author-document` and
  `plan-migration` carry the collection's actual field list, `revise-document`
  carries the document's resource URI, and `fix-error` resolves the recovery text
  from this build's own error knowledge. A prompt that only said "author a
  document nicely" would be a sentence the user could have typed, and would carry
  no reason to live on the server.

  They also encode the order of operations an agent has no way to infer:
  `write_content` validates before it writes, `write_content` replaces rather than
  patches so untouched frontmatter must come back byte-identical, migrations are
  reviewable commits, and `graft migrate --apply` is the operator's consent to
  propose rather than to run.

  Prompt arguments and resource-template variables autocomplete from what exists:
  collection names, document slugs narrowed to the collection already chosen, and
  error codes matched case-insensitively. Picking a collection that is not
  registered is a mistake the server can prevent rather than diagnose.

  `mcp` · [23b73fa](https://github.com/AndersonDesign1/graft/commit/23b73fa)

- Return `resource_link` blocks from the content tools, so an answer that names
  documents also says where they are.

  `list_content`, `get_content`, `search_content` and `write_content` now carry a
  link per document alongside their text. A tool answering "here are eleven
  documents" and leaving the client to reconstruct eleven URIs was asking it to
  know the scheme; a search hit already knows its collection and slug, so the
  link saves a `get_content` round trip to reach the document itself.

  The URI scheme lives in one module now, used by both the links and the resource
  registration. Two spellings of it would be two things to keep in step, and the
  failure would be silent — a link pointing at a URI nothing serves reads as a
  broken client rather than a server that disagrees with itself. The test follows
  a link through `readResource` rather than comparing strings.

  The text block stays first, so a client that ignores links loses nothing, and
  a failure emits no links at all.

  `mcp` · [9859435](https://github.com/AndersonDesign1/graft/commit/9859435)

- Serve authored documents and the project schema as MCP resources.

  A tool call is a request to do something; a resource is a thing that exists,
  addressed by URI, which a client can list and attach to a conversation without
  spending a turn deciding to. Graft's documents were reachable only as tool
  output, so attaching one meant an agent calling `get_content` and pasting the
  answer — and they are files with stable paths, the most resource-shaped thing
  in the product.

  URIs are `graft://<branch>/<collection>/<slug>`, with the branch baked into the
  template rather than left as a variable a server would only have to refuse.
  Reads come from the authored files, not the index, so a resource reflects the
  working tree rather than the last compile, and works on a static project with
  no database. `graft://<branch>/schema` serves the same payload as
  `describe_schema`, for attaching once instead of fetching each time.

  The template's URI variables autocomplete from what exists, and the slug list
  narrows to the collection already chosen.

  Also adds `guardedResource`, so a failed resource read carries its `fix`. A
  tool failure is a value that can hold the code, the fix and the recovery text;
  a resource read has no such envelope, and a GraftError escaping raw arrived
  with its fix stripped off.

  `mcp` · [0db04f7](https://github.com/AndersonDesign1/graft/commit/0db04f7)

- Declare what every MCP tool does to the world, and answer with data as well as
  prose.

  All 18 tools now carry `ToolAnnotations` — the hints the protocol has had since
  2025-03-26 and Graft shipped none of. Every tool looked identical to a client,
  so `search_content` and `delete_content` were offered on the same terms, and a
  client that asks a human before a destructive call had nothing to key on. Reads
  are `readOnlyHint`, `delete_content` / `put_asset` / `decide_approval` /
  `run_function` are `destructiveHint`, `write_content` is a non-destructive
  change where `contentDir` is a git work tree, which is the ordinary case and
  the one the hint is set for — outside one an overwrite has no undo, and the
  hint is then optimistic. Nothing claims an open world: the domain is the
  collections a project declares.

  These are hints and not a boundary, exactly as the spec says. Graft's real gates
  are unchanged — the scope check, the one-shot input-bound approval, the Postgres
  role separation. This makes an honest client's UX correct; it does not make a
  dishonest one safe.

  Tool results also carry `structuredContent` (MCP 2025-06-18) whenever the
  payload is an object. Every tool already built a JS object and serialised it, so
  each caller parsed the prose back into the shape it started as. The text block
  is byte-identical, so a client that predates the field sees no change.

  `@modelcontextprotocol/sdk` moves to ^1.30.0.

  `mcp` · [fe8b02f](https://github.com/AndersonDesign1/graft/commit/fe8b02f)

- Add `@usegraft/sdk-tanstack-start`. Same `createGraft` surface as every other
  adapter, so `getContent("docs", slug)` returns the type your `defineCollection`
  declared and an unknown collection name is a compile error, plus `graftRoute`
  for mounting the functions and MCP handlers on a server route. Typed
  structurally, so the package depends on no TanStack code and there is no peer
  version to keep in step.

  The framework-specific warning is about loaders. TanStack Start route loaders
  are isomorphic — they run on the server for the first paint and in the browser
  on client-side navigation — so a handle holding a database connection belongs in
  a server function or a server route, never in a loader. Astro frontmatter is
  the only adapter here that is server-side unconditionally; SvelteKit has the
  same split under different names, where a `load` in `+page.server.ts` is
  server-only and a universal `load` in `+page.ts` also runs in the browser.

  Add `@usegraft/sdk-react-router` for React Router v7 framework mode, which
  absorbs the Remix path. Same surface again, with one difference worth knowing
  before you mount anything: React Router splits a route by method into two
  exports instead of naming the method, so a functions endpoint exports both
  `action` (POST) and `loader` (GET). The `loader` line looks redundant and is
  not — without it a GET gets React Router's own "no loader" error rather than
  Graft's 405 with an `Allow` header and a fix.

  Its `graftRoute` test builds a typed `LoaderFunctionArgs`-shaped value rather
  than passing an object literal inline. TypeScript's excess property check
  rejects the literal, which would have made the test assert the opposite of what
  it claims: that the mount accepts only `{ request }`, when the point is that it
  accepts the larger object React Router actually passes.

  Add `@usegraft/sdk-react`, the browser client, which only became coherent once
  `@usegraft/content-api` shipped. The open question was types across the wire,
  and the answer is that they do not cross it: the app imports its own
  `collections` from `graft.config.ts` at compile time exactly as a server adapter
  does, and `createContentApiReader` supplies data over HTTP. No codegen, no
  generated client, no schema fetched at runtime — the wire carries documents.

  `createGraft({ endpoint, collections })` returns the same three reads as every
  other adapter. On top of that sit `createGraftHooks(graft)` and three hooks that
  run those reads and report `{ data, error, loading, refresh }`. The hooks come
  out of a factory rather than being importable directly because that is what
  keeps them typed: a hook reading an untyped context could only promise
  `Document<AnyCollection>`, which makes `data.title` unknown and a misspelled
  collection name a runtime surprise.

  They are a binding, not a cache. No deduplication, no retries, no
  stale-while-revalidate, and `data` answers the current arguments or is
  undefined. Apps have TanStack Query or SWR for that and `graft.getContent` is a
  plain async function that composes with either; a worse copy of a query client
  inside an SDK is the wrong trade.

  Two things this turned up. `branch` is silently dropped over the wire — the
  content API pins its branch server-side and rejects a branch query param, so a
  client that set one would read main while believing it read a preview. Passing
  `branch` with `endpoint` is now a `CONFIG_INVALID` at construction that says to
  point at the preview's own endpoint. And `packages/sdk-react` carries the only
  jsdom config in `packages/`: `useEffect` never runs without a DOM, so a
  Node-environment test of these hooks would have asserted the loading state and
  nothing else — precisely the part that cannot break.

  `sdk-react`, `sdk-react-router`, `sdk-tanstack-start` · [fcfa66f](https://github.com/AndersonDesign1/graft/commit/fcfa66f)

## 0.2.0

Released August 27, 2026.

### Breaking changes

- Approval decisions are attributed to a verified identity, never to caller input.

  `decideApproval` took a `decidedBy: string` that every surface let the caller
  supply. That string is the entire separation-of-duties control — the UPDATE's
  WHERE clause compares it against `requested_by_id` — so anyone who could name it
  could approve their own destructive operation by naming somebody else. The
  guard was decorative.

  **Breaking:**

  - `decideApproval(db, id, decision, decidedBy)` now takes an `ApprovalDecider`
    (`{ kind, id }`) instead of a string.
  - The MCP `decide_approval` tool no longer accepts a `decidedBy` argument. It
    attributes the decision to the identity the connection authenticated as, via
    the new `connectionActor` option, and refuses an unauthenticated connection.
  - `POST /api/studio/v1/approvals/{id}/decide` ignores `decidedBy` in the body;
    the Studio's `decidedBy` mount option is now `decider: ApprovalDecider`.
  - An approval whose requester has no stable id is **undecidable**
    (`APPROVAL_UNATTRIBUTED`). The old `requested_by_id IS NULL` arm made those
    approvable by anyone, including whoever filed them.
  - A human-gated function called by an actor with no stable id is refused with
    `UNAUTHORIZED` instead of filing an approval nobody can be accountable for.

  Adds a `decided_by_kind` column (migration `0008`) so attribution records what
  kind of actor decided, matching `requested_by_kind`.

  `cli`, `contracts`, `core`, `db`, `mcp`, `studio` · [61b9ac4](https://github.com/AndersonDesign1/graft/commit/61b9ac4)

- `graft compile` refuses executable MDX, and the project declares its own trust.

  `MdxBody` refuses `{…}` expressions and `import` at render by default, and the
  write paths refuse them on the way in. `graft compile` checked nothing, on the
  reasoning that content already in git arrived through code review.

  That left the compiler and the renderer disagreeing. A git-authored expression
  body compiled, indexed, and then failed at render, per request, on the page, in
  production. Compile now checks every authored body and reports every offending
  document at once, so the failure lands at build time.

  `export const mdxTrust = "full"` in `graft.config.ts` is the escape, for the
  case ADR 0004 names: every author has commit access, so code review really is
  the control. It defaults to `"restricted"`, and an unrecognised value is refused
  rather than defaulted.

  `MdxBody`'s `trust` prop is unchanged. The two settings have to agree, so
  compile's error names both.

  **Breaking:**

  - `graft compile` fails on authored MDX containing `{…}` expressions, `import`,
    `export` or spread attributes, unless the project sets `mdxTrust = "full"`.
    Evidence the break is narrow: all 28 authored `.mdx` files across both
    examples compile unchanged.
  - MDX the checker cannot parse is refused rather than indexed.

  **New:** `readDocs` takes a third options argument; `CompileOptions`,
  `CompileStaticOptions`, `GraftMcpOptions` and `StudioApiOptions` gain an
  optional `mdxTrust`. All default to `"restricted"`, so a call site that omits it
  is safe rather than permissive. `MdxTrust` is declared in `@usegraft/mdx-safety` and
  re-exported from `@usegraft/sdk-next`, which used to declare its own copy of
  the same union. Same name, same shape, so nothing importing it has to change.

  See `docs/adr/0006-compile-refuses-executable-mdx.md`.

  `cli`, `compiler`, `mcp`, `mdx-safety`, `sdk-next`, `studio` · [52d7488](https://github.com/AndersonDesign1/graft/commit/52d7488)

- Host validation and CSRF protection for the local Studio.

  A loopback Studio has no authentication by design, so anything that can reach
  `127.0.0.1` can act. Browsers let a page do exactly that: every Studio mutation
  is a plain POST/PUT parsed with `request.json()`, which ignores Content-Type —
  so a cross-origin "simple request" carrying `text/plain` executed with no CORS
  preflight. The attacker cannot read the response, but the side effect already
  happened: an approval decided, a document overwritten, a commit made.

  **Breaking:**

  - `createNodeListener(handler, { allowedHosts })` refuses a request whose `Host`
    is not one it answers to, with 400. `graft serve` and `graft studio` derive
    the list from their bind address. Without this an attacker-chosen Host flowed
    into every handler, and a browser resolving any name to `127.0.0.1` is exactly
    how DNS rebinding reaches a loopback bind.
  - The Studio API refuses state-changing requests whose `Origin` is cross-origin,
    and requires `Content-Type: application/json` on them — which forces a
    preflight for anything that omits Origin. Reads are unaffected.

  The shell redirect is now `Cache-Control: no-store`: it is built from the
  request's own Host and fires before any authorization runs, so a 302 cached by
  path alone would outlive the Host check.

  The Vite dev proxy rewrites `Origin` to the API's origin, since in development
  the browser's origin is the Vite server rather than the Studio.

  `cli`, `studio` · [4bde361](https://github.com/AndersonDesign1/graft/commit/4bde361)

- MCP over HTTP fails closed. Authentication is no longer opt-in.

  `createGraftMcpHandler` had `requireActor?: boolean` defaulting to **off**, on a
  handler whose own docs advertise embedding it in "a Next.js route, a self-host
  container, Vercel Fluid, or a Worker". Forgetting it published `write_content`,
  `put_asset`, `delete_content` and `decide_approval` to anyone who found the URL —
  and unlike `graft serve`, a library embedding got no warning at all.

  **Breaking:**

  - `requireActor` is replaced by `allowAnonymous`, which defaults to `false`.
  - Constructing a handler with neither an `actor` resolver nor
    `allowAnonymous: true` now **throws** (`CONFIG_INVALID`). A deployer who
    forgets gets a startup failure with a fix line instead of an open endpoint.
  - `graft serve` derives the default from the bind host: anonymous MCP is served
    on loopback (zero-config local dev) and refused anywhere else.
    `GRAFT_MCP_REQUIRE_AUTH` is retired — its secure value (`1`) is now the
    default, so deployments that set it are unaffected. Off loopback,
    `GRAFT_MCP_ALLOW_ANONYMOUS=1` is a deliberate, warned-about opt-in for
    operators fronting the server with their own auth proxy.
  - The insecure-bind warning now tests what is _enforced_. It previously treated
    "a dev token exists" as sufficient, so setting `GRAFT_DEV_TOKEN` silenced the
    warning while anonymous callers kept reaching `decide_approval`.

  `cli`, `mcp` · [2eb24ed](https://github.com/AndersonDesign1/graft/commit/2eb24ed)

- One path-containment helper, and it refuses symlinks.

  `resolveContained(root, path)` (new, in `@usegraft/compiler`) checks the bytes
  _and_ the filesystem. Lexical containment — `resolve` plus a prefix check —
  only answers "does this string stay under the root", which a symlink **inside**
  the root silently defeats: `docs/notes.mdx -> ~/.ssh/id_rsa` passes every string
  test and `readFileSync` then follows it. Git can commit symlinks, so a cloned
  template can plant one.

  **Breaking:**

  - MCP `put_asset` no longer reads arbitrary server paths. Its `path` argument
    requires the new `localUploadRoot` option, which only `graft mcp` sets (to the
    project directory) — every remote mount refuses it. Previously the raw string
    went to `readFileSync`, the bytes were stored under a caller-chosen key, and
    the response included a fetchable URL, so one call read `.env` off the server.
  - Studio `writeDocument` validates the slug against `SLUG_RE` and contains the
    resulting path. `parseDocument`'s existing check did not help: it validates
    `basename(sourcePath)`, which strips exactly the `..` segments that make a
    path dangerous.
  - `loadItem` validates the item name before joining it onto the registry root.
    `describe_item` passed a raw MCP argument through, and the three error
    branches were distinguishable — a filesystem existence oracle.
  - `safeContentPath` now refuses symlinks, so a hostile repository can no longer
    leak files through the changes-diff endpoint.

  Also fixes `looksBinary`, which read an entire file into memory to inspect its
  first 8KB — one large file made every diff render allocate all of it.

  `SLUG_RE` is now exported from `@usegraft/compiler`.

  `cli`, `compiler`, `mcp`, `registry`, `studio` · [92fe85e](https://github.com/AndersonDesign1/graft/commit/92fe85e)

- Rate limits key on the real peer address, and concurrency can no longer outrun
  them.

  Every rate limit in the product was bypassable with a header.
  `clientIp` read `x-forwarded-for.split(",")[0]` — the **leftmost** entry, which
  under XFF's append semantics is whatever the original client wrote. Rotating the
  header minted a fresh bucket per request, defeating per-function limits, the
  handler-wide backstop, and the anti-brute-force property they exist for.

  Separately the limiter counted prior audit rows, ran the handler, and recorded
  its row afterwards — a window spanning the entire invocation. N concurrent
  requests all read the same count, all saw room, and all ran.

  **Breaking:**

  - `AuditStore.record(entry)` is replaced by `reserve(entry) => id` and
    `settle(id, outcome)`. The row is inserted before the call is admitted, so the
    counter and the evidence are the same row. A row left `in_flight` is a crashed
    or still-running invocation, which is worth being able to see.
  - `FunctionsHandlerOptions.trustedProxyHops` (default `0`) controls whether
    `x-forwarded-for` is read at all. At `0` it is ignored entirely. At `n`, the
    nth entry **from the right** is used — the address your own nearest proxy
    observed, which a client cannot forge past. Set it to the number of proxies
    you run.
  - `runtimeRoleGrantsSql` grants `UPDATE (status, duration_ms) ON audit_log`.
    Column-scoped deliberately: the runtime may record how a call ended, never
    rewrite who made it or what it counted against.

  `PEER_HEADER` (`x-graft-peer`) is exported from `@usegraft/contracts`. Graft's
  Node adapter strips any inbound copy and sets it from the socket, so unlike
  `x-forwarded-for` it cannot be written by a client.

  `cli`, `contracts`, `core`, `db` · [ed103a8](https://github.com/AndersonDesign1/graft/commit/ed103a8)

- Authorization is per-route and scope-based. Being authenticated is no longer
  enough to reach operator-only surfaces.

  The Studio's `authorize` was `(request) => boolean` — an interface too narrow to
  express "may this actor do _this particular thing_", so callers invented their
  own policy. What `graft serve` invented was `actor.kind !== "anonymous"`, which
  admits every agent: `GRAFT_DEV_TOKEN` mints `{ kind: "agent" }` and OIDC issuers
  default to the same. Any agent holding a normal runtime token could approve
  destructive operations, write documents, commit, and revert.

  MCP had the mirror problem: scopes were consulted only inside `run_function`'s
  access rules, so `write_content`, `put_asset`, `delete_content` and
  `decide_approval` were available to any authenticated caller whatever their
  token permitted.

  **Breaking:**

  - `StudioApiOptions.authorize` is replaced by `authenticate`, which returns a
    `StudioPrincipal` (`{ kind, id, scopes }`) or `null` to refuse.
  - Studio routes require scopes: `studio:read` for reads, `studio:write` for
    mutations, `approvals:decide` for the approval decision. The requirement is
    computed in one place, so a new route cannot be added without one.
  - MCP `write_content`, `put_asset` and `delete_content` require `content:write`;
    `decide_approval` requires `approvals:decide`.
  - Approval decisions made through an authenticated Studio are attributed to the
    caller, not the mount-time identity.
  - `graft mcp` grants `content:write` locally (it runs on the operator's own
    machine) but deliberately not `approvals:decide`, so the CLI requesting and
    `graft approve` deciding remain different identities.
  - The landing-page example stops granting every self-registered account
    `submissions:read commerce:orders:read commerce:orders:write`. Scopes now come
    from a `GRAFT_OPERATOR_EMAILS` allowlist; one free signup previously dumped
    every contact submission and could mark arbitrary orders paid.

  `cli`, `mcp`, `studio` · [6ebfe18](https://github.com/AndersonDesign1/graft/commit/6ebfe18)

- Field builders can bound what they accept, and query limits are clamped
  server-side.

  `FieldOptions` carried only `optional` and `description`, so every authored
  string and every public form input compiled to a bare `z.string()` and was
  written verbatim into an unbounded jsonb column. A single anonymous request
  could store megabytes; an unbounded quantity multiplied by a price silently
  exceeded `Number.MAX_SAFE_INTEGER` and stored a wrong total rather than being
  rejected.

  **New options:** `maxLength` (string/text), `min` / `max` / `int` (number),
  `pattern` (string/text), and `maxItems` on `field.array`.

  **Breaking:**

  - `listRecords` clamps `limit` to `MAX_RECORD_LIMIT` (500) and coerces nonsense
    values to the default. It previously passed a caller-supplied number straight
    to `LIMIT`, so a public query could ask for a billion rows — or a negative
    one, which made Postgres error.
  - `listRecords` gains `match`, which filters on `data` fields **in SQL**.
    Filtering after the row cap is a correctness bug, not just a slow path:
    non-matching rows still consume the window. `listComments` filtered
    `approved && pageSlug` in JavaScript afterwards, so posting enough unapproved
    comments emptied every approved comment on every page, silently.

  The bundled `comments` and `commerce` primitives now bound every input,
  `placeOrder` caps `items` at 100, and `loadProducts` batches the catalog lookup
  into one `inArray` query instead of one round-trip per slug — that loop ran
  _before_ unknown slugs were rejected, so a request full of bogus slugs held a
  pooled connection for thousands of serial queries and only then failed
  validation.

  `products.currency` is constrained to three letters, so one malformed product
  can no longer take down the catalog page via `Intl.NumberFormat`.

  `core`, `registry` · [e0d4eda](https://github.com/AndersonDesign1/graft/commit/e0d4eda)

- The hardened runtime role can project content, and the container hardens by
  default.

  `graft harden` denied the runtime role `INSERT`/`UPDATE` on `content_index` and
  `compilations`, so hardening cost a deployment its MCP content writes. That made
  the second layer of the approval gate something you traded a working feature
  for, which is why it was opt-in and applied to nothing.

  The denial was not buying what it looked like. `write_content` writes the MDX
  file and then compiles, and compile is the step that reaches Postgres, so
  whoever holds the runtime credential could already project content through the
  application. Withholding the grant removed a feature, not a capability.

  The property worth enforcing is narrower and untouched: no `UPDATE` on
  `approvals`, so `pending → approved` stays unreachable for the runtime even with
  raw SQL. Removals are a soft delete, so `DELETE` on `content_index` stays
  ungranted, and `migrations_applied` stays operator-only.

  **Security fix, found reviewing the above.** The `approvals` INSERT grant was
  table-level, and `status` is plain text with a `DEFAULT` rather than a `CHECK`.
  Postgres lets a table-level `INSERT` grantee name every column, so the runtime
  credential never needed to flip a pending row: it could file one that was
  already `'approved'` and consume it, and `decideApproval` (with its
  separation-of-duties predicate) would never run. Withholding `UPDATE` alone was
  not the control it read as. The grant is now column-scoped to the seven columns
  an approval request actually writes, so `status`, `decided_by`, `decided_at` and
  `decided_role` fall back to their defaults.

  This predates the changes above, but shipped dormant behind an opt-in nobody
  ran. Turning hardening on by default is what would have made it live.

  **Breaking:**

  - `runtimeRoleGrantsSql` emits two more `GRANT` statements. Re-run
    `graft harden <role>`; existing hardened roles keep working with the old,
    narrower grants until you do.
  - The all-in-one container serves under the hardened runtime role by default.
    Set `GRAFT_HARDEN=0` for the previous behaviour. `GRAFT_MODE=serve` stays
    opt-in, because there the database is yours rather than the container's.

  See `docs/adr/0005-hardening-is-the-default-where-the-container-owns-the-database.md`.

  `db` · [52d7488](https://github.com/AndersonDesign1/graft/commit/52d7488)

- Authored MDX no longer executes as JavaScript by default.

  `MdxBody` compiles with `outputFormat: "function-body"` and evaluates via
  `run()`, which is `new Function` in the host runtime — so `{expr}` and `import`
  in a stored body are arbitrary server-side JavaScript with `process`, `fetch`
  and dynamic `import()`. For content the operator wrote and reviewed in git, that
  is the feature. It stops being one as soon as an author is not the operator: a
  hosted Studio, or one a user runs for their own writers, makes "can write
  content" mean "can execute code on the render host" — and on shared
  infrastructure, on other tenants' hosts too.

  New package **`@usegraft/mdx-safety`**. It removes the executable surface rather
  than trying to contain it: `node:vm` is explicitly not a security boundary, and
  a worker thread cannot return React elements without breaking component identity
  under RSC. Prose, GFM and components with literal attributes all survive;
  `{…}` expressions, `import`, `export`, expression-valued attributes and
  `{...spread}` attributes are refused.

  **Breaking:**

  - `MdxBody` gains `trust?: "restricted" | "full"`, defaulting to `"restricted"`.
    Pass `"full"` only for bodies you know came from your own repository.
  - MCP `write_content` and Studio document saves refuse executable MDX.

  Checked at render as well as at write, because content can also arrive through a
  direct database write with the runtime credential — a path no write-side guard
  sees. All 28 authored files across both examples pass unchanged.

  `mcp`, `mdx-safety`, `sdk-next`, `studio` · [1aea0da](https://github.com/AndersonDesign1/graft/commit/1aea0da)

### Features

- Every package ships a README, a description, keywords and a LICENSE.

  `0.1.1` published sixteen packages with no README and, for fourteen of them, no
  `description` either. On npm that renders as a blank page and an unsearchable
  listing: `description` is the line npm search shows, and without keywords the
  packages are findable only by exact name.

  Each README says what the package is, how to install it, and shows one real
  example using its actual exports. The security-relevant ones state their
  defaults plainly, because "MdxBody refuses executable MDX by default" is
  something a reader should not have to find in an ADR.

  `LICENSE` is now copied into each package. `files: ["dist"]` does not exclude
  `README.md` or `LICENSE` (npm always packs those), but a licence file only ships
  if it exists in the package directory, and the root one does not count.

  `assets`, `auth`, `cli`, `compiler`, `content-migrations`, `contracts`, `core`, `db`, `mcp`, `mdx-safety`, `registry`, `sdk-astro`, `sdk-core`, `sdk-next`, `studio`, `tokens` · [f423a6e](https://github.com/AndersonDesign1/graft/commit/f423a6e)

- Thirteen self-contained defects, led by a data-loss bug in the editor.

  - **The editor wrote one document's content into another document's file.**
    `persist` took `collection`/`slug` from the current route closure while taking
    the bytes from a ref. On an A → B navigation React re-renders with route=B
    before the pending flush runs, so A's edits were written to B's path,
    destroying it — and the toast said "Saved B/B". Identity now comes from the
    same snapshot as the content, via a new `buildSavePayload` that takes it as an
    argument, which makes the mismatch unrepresentable. Document loads are
    sequenced so out-of-order responses cannot show one document under another's
    route.
  - `updateRecord` read, merged and wrote with no lock, so two concurrent callers
    both merged over the same baseline and the second silently erased the first.
    Now one transaction with `SELECT … FOR UPDATE`, and the "unreachable"
    `throw new Error` is a real `DOCUMENT_NOT_FOUND`.
  - A malformed URL escape white-screened the Studio (`parseHash` runs in a
    `useState` initialiser, so the `URIError` threw during first render) and
    turned Studio asset and route-id requests into misleading 500s.
  - The compiled static index briefly did not exist: `rmSync` then `renameSync`
    left a window where readers got `STATIC_INDEX_NOT_FOUND`, and a crash between
    them destroyed the artifact. Now an atomic rename, falling back only on
    Windows.
  - Data migrations persisted the raw transform output instead of the validated
    result, so Zod defaults and coercions never reached the stored rows, and
    compared with key-order-sensitive `JSON.stringify` against jsonb that
    normalises key order — rewriting identical rows.
  - The generated `graft/index.ts` barrel emitted duplicate imports when two
    filenames collapsed onto one identifier (`my-mod.ts` and `myMod.ts`), in a
    file marked "do not edit". It now refuses and names both offenders.
  - `graft dev` kept watching the original content directory after a config reload
    moved it, so every later save was invisible.
  - OIDC tokens without a `sub` claim authenticated as an actor with no id.
    `requiredClaims: ["sub"]` now rejects them.
  - `applyPlan` trusted a conflict snapshot taken when the plan was built; it now
    re-checks disk immediately before each write.
  - A `??` chain in the pages-description migration short-circuited on an empty
    string, so the documented title fallback was never reached.
  - All four GitHub Actions are pinned to commit SHAs, and the release workflow's
    `id-token: write` is scoped to the publish job rather than the whole file.

  `auth`, `core`, `db`, `registry`, `studio` · [d6cbc3d](https://github.com/AndersonDesign1/graft/commit/d6cbc3d)

- Fixes found by independent review of the hardening work itself.

  - **The rate-limit peer is no longer a header.** `x-graft-peer` was stripped and
    re-set by Graft's Node adapter, which is sound for `graft serve` and worthless
    in a Next.js or Astro route that passes the browser's Request through
    untouched — a client could send the header and choose its own bucket. That is
    the `x-forwarded-for` bug, relocated to a header nobody knew they had to
    strip. The peer is now registered against the Request object in-process
    (`setRequestPeer` / `getRequestPeer`), which nothing over the wire can forge.
    `PEER_HEADER` is removed. Deployments with no adapter share one `unknown`
    bucket unless they declare `trustedProxyHops`; both examples now do.
  - **`@usegraft/mdx-safety` parses what the renderer parses, and fails closed.**
    The checker used `remark-parse` + `remark-mdx` while `MdxBody` compiles with
    `remark-gfm` — so source that failed to parse here but compiled there was
    waved through by the old "unparseable means nothing to execute" shortcut. GFM
    is now enabled on both sides, and unparseable source throws
    `UncheckableMdxError` instead of returning `[]`.
  - **Scripting elements and inline event handlers are refused.**
    `<script>alert(1)</script>` and `<img onerror="…">` contain no `{}`
    expression, so the expression checks never saw them. The module now documents
    that it is not a general HTML sanitiser.
  - **`createGraftMcp` fails closed when `actor` is set without `connectionActor`.**
    That combination silently disabled every MCP write-tool scope check, and it
    shipped in one of our own example scripts.

  `cli`, `contracts`, `core`, `mcp`, `mdx-safety` · [301c817](https://github.com/AndersonDesign1/graft/commit/301c817)

- An approval is filed pending by construction.

  Migration `0009_pending_by_construction` adds a `CHECK` on `approvals.status`
  and a `BEFORE INSERT` trigger that refuses any insert naming a status other
  than `'pending'`, or carrying a decision in `decided_by`, `decided_at`,
  `decided_role` or `decided_by_kind`.

  The column-scoped INSERT grant already stopped a hardened runtime role from
  filing an approved approval. That is a grant list, and a grant list is a thing
  someone edits. This puts the same rule in the table, so it holds for every role
  including the owner, and no future change to `runtimeRoleGrantsSql` can quietly
  reopen it.

  The trigger raises rather than coercing the row to `'pending'`. A caller trying
  to file a decision has a bug or is an attacker, and silently rewriting the row
  would hide both.

  Deciding is an `UPDATE` and is untouched.

  **Requires a migration.** Run `graft db migrate` (or
  `node packages/db/scripts/migrate.mjs`) before serving on an existing database.

  `db` · [02690dd](https://github.com/AndersonDesign1/graft/commit/02690dd)

## 0.1.1

Released August 25, 2026.

### Fixes

- `graft --version` reported `0.0.0` instead of the released version.

  The version was a hardcoded constant that changesets never touched, and the test
  asserted `toContain("0.0.0")` — so it passed _because_ of the bug. The version is
  now read from the manifest at runtime, and the test asserts against that value
  rather than a literal.

  `cli` · [6737b5b](https://github.com/AndersonDesign1/graft/commit/6737b5b)

## 0.1.0

Released August 25, 2026.

### Features

- Initial public release of the Graft packages: contracts, core, db, assets,
  compiler, content-migrations, auth, sdk-core, sdk-next, sdk-astro,
  sdk-sveltekit, cli, mcp, registry, studio, tokens. All packages version
  together (fixed group) while pre-1.0.

  `core` · [8d8eda0](https://github.com/AndersonDesign1/graft/commit/8d8eda0)
