Changelog

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

Reference98 min read

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, 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 codeMeaning
    0It worked
    1It failed; the error says why and what to do
    2The command was called wrong: an unknown command or flag, or a missing argument. Nothing ran
    3A person has to approve the action first (DESTRUCTIVE_OP_REQUIRES_APPROVAL)
    4The 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:

    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

  • 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.

    CommandTool
    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 --valuewrite_content for each document
    graft content delete <collection>/<slug>delete_content
    graft drafts, drafts publish, drafts discardlist_drafts, publish_drafts, discard_drafts
    graft schemadescribe_schema
    graft fn, fn describe, fn runlist_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

  • 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

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

  • 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.

    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

  • 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

  • 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

  • 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

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

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:

    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

  • 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

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:

    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

  • 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

  • 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

  • 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:

    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

  • 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

  • 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

  • @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

  • 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

  • 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

  • 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:

    {
      "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

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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

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

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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

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

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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • @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

  • 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

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

  • 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

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

  • 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

  • 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

  • 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

  • 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

  • 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

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

  • 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

  • @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

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:

    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

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

  • @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

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:

    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

  • 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

  • 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

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

  • 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

  • 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

  • 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

  • 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

  • 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

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

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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

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.

    // 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

  • 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

  • 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

  • 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

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

  • 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

  • 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

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

  • 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

  • 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

  • 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.

    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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

  • 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

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

  • 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

  • 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

  • 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

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

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