Article ·

Letting an AI agent edit a site while keeping control of publication

A ban on publishing does not necessarily protect published text from edits. K39 shows how to separate an agent's proposal from the page visitors read—and how to test that separation.

Imagine a routine task: an AI agent needs to expand a place description on your site and prepare answers to common questions. You want to read the result first. Until you approve the edit, visitors should receive the previous page.

Withholding permission to publish seems sufficient. But the page is already published. If a tool can write new text directly into it, visitors will see the edit without any transition to a “published” state.

K39, my travel guide to Kaliningrad Oblast, stores permitted edits to an existing public place page as a separate proposal. This article follows how that proposal is saved, what readers receive in the meantime, and a bug in the code that applies it. The opening is a hypothetical example, not an account of a user incident. The analysis uses public commit 0420863 and local verification from 28 October 2026.

Why blocking publication is not enough

A page's status answers whether readers can access it. It does not say who may replace its contents.

Suppose an agent submits a new title and body without specifying a status. Preserving the existing status is reasonable for an ordinary partial update. If it was published, it will stay that way—including after the new text has been written.

The write decision therefore needs to consider the caller's permissions, the page's current state and the requested action. Checking only the incoming publicationStatus field misses this case. K39 makes the distinction in its write-routing logic.

The agent saves a proposal; readers receive the existing page

An agent works with K39 through MCP, an interface for calling tools. The server determines permissions from an access token: draft allows material to be prepared, while editor permits publication and edits to published material.

For a place page, draft access has three distinct behaviors:

  • An unpublished draft can be edited directly.
  • Permitted text edits to a published or archived page are saved separately as a proposal.
  • An explicit request to set published is rejected with a 403 error.

The proposal lives in a separate table, content_drafts. Sequential edits can accumulate there: first a title, then the body. Public reads still use the main page record. Prepared material can therefore be saved without replacing what visitors see. Edit handler, public reader.

An editor can inspect the differences in the admin interface, then apply or discard the proposal. Applying it updates the main record and deletes the consumed draft in one transaction. Subsequent section synchronization is outside that transaction. Editor actions, proposal application, subsequent work.

This separation depends on the permissions actually granted. Give an agent the editor token and it gets editor capabilities. In the inspected implementation, identical values for both configured tokens also resolve to editor. Calling one token “draft” is insufficient; what matters is the access the server resolves. Token handling.

A successful save does not explain what readers saw

One detail is easy to misread: a tool response can contain the new title, publicationStatus: published and pendingReview: true at the same time.

There is no contradiction. The title belongs to the proposal; the status belongs to the original page. But looking only at the command response could suggest that the new text is already public.

Verification needs both sides: the proposal was saved, and the main record still contains the previous text. After an authorized apply, the opposite should hold: the main record contains the new fields and the consumed proposal is gone. That is what the scenario test checks.

Permission to apply an edit does not ensure it gets applied

A bug in K39's history illustrates the difference between an allowed command and a correct result.

An editor requested publication and supplied a slug, the page identifier used in its address. The earlier code treated the mere presence of slug as a content edit. Instead of applying the pending text, it saved the original page and deleted the proposal.

The fix distinguished an unchanged slug, which only identifies the page, from a new value that changes its address. A publication request without content edits then applied the pending proposal transactionally. The 16 October fix documents this behavior. The commit does not establish whether real publications or users were affected.

A test that an editor may request published would miss this bug. The result of the whole operation needs checking: which text remains in the main record, and what happened to the proposal. Identifying an object and changing it can look alike in JSON while expressing different actions.

What this design does not handle

A separate proposal does not make every agent command safe. For example, draft-scope changes to photos and schedules on a published place are rejected rather than automatically becoming drafts. Venues have their own proposal rules. Refusal tests.

The apply transaction also does not prove that concurrent edits will apply exactly the version an editor saw earlier. That needs separate version and conflict controls; places and venues use different mechanisms. The sequential test above does not exercise every such situation. Generic draft saving.

Finally, publication permission does not replace editing. Separate review in K39 is a workflow rule: the inspected path does not require approval from a second independent user. Even approved text can contain incorrect information; source verification remains separate work. Editorial workflow, publication path.

For another agent integration, I would start with one testable requirement: until a proposal is applied with the necessary permissions, its prepared text must not replace the public text. I would then check it against an already published page, using a request with no status field. That scenario forces an explicit answer to where the proposal lives, where the site reads from, and what the apply command actually does.

Reproducing the scenario

During preparation of the first version of this article, 181 tests passed across seven selected files: 28 function and call-structure checks, and 153 tests against disposable PostgreSQL 18. This was local verification on 28 October 2026, not an audit of the deployed server.

The core scenario creates a published page, saves new fields under draft access without specifying a status, and checks that the main record is unchanged. It then applies the proposal under editor access and checks the new text and removal of the consumed draft.

To run the file containing this and related scenarios, use a checkout of 0420863, dependencies from its lockfile, Node 24.21.0, pnpm 12.15.0 and a running Docker engine. From the K39 root:

env -u DATABASE_URL_TEST -u DATABASE_URL -u NUXT_DATABASE_URL \
  pnpm exec vp test run --project packages/database \
  packages/database/src/repository/test/pending-publish.writer.test.ts

This file contains 22 tests from that selection. Without a configured database URL, the test setup provisions disposable PostgreSQL through Testcontainers. Run prerequisites.

These tests select permissions inside the process; separate unit tests check token-to-scope mapping. A real client and the deployed MCP server were not part of this verification. The result does not establish which tokens agents hold in production.