Skip to main content

Native Craft sessions, leases, agent tasks and whole-document API

Word (WordCraft) and Sheet (GridCraft) run inside Crewship (#5315): each document engine is a WebAssembly module run inside the server by wazero, each editor a pinned bundle served by the editor origin. They are not part of the binary. A release binary carries a catalog: for each application the version and the SHA-256 of its package’s manifest.json (craftassets.releaseCatalog, set at build time), and the address the packages are published at. When an administrator turns an application on, Crewship downloads crewship-craft-<application>.craftpkg, unpacks only regular files with valid names, accepts it only if its manifest matches the pin and every file matches the manifest, and moves it into <data directory>/craft-components/<application>/<pin>/ (craft_native.components_dir). It then serves the editor, fences it with the native credential gate and registers the profile, without a restart. Offline, the same package is installed from a file (crewship admin instance applications install --file). Installed packages are verified again at every start and every read; versions live side by side, because documents stay pinned to the engine they were written with. A build without a catalog (an ordinary go build) trusts no package. The server’s operator can replace the pins with CREWSHIP_CRAFT_COMPONENT_PINS, in a release binary too (logged at every start; the components report development: true), and name a mirror with CREWSHIP_CRAFT_COMPONENT_URL, which does not change what is trusted. On a system without a suspend-aware clock (today: not Linux) the server starts and both applications report clock_unsupported. Whether people may use an application is the instance administrator’s setting (craft_application_settings, Admin › Artifacts › Applications, crewship admin instance applications); Word, Sheet and agents start off. The setting is applied per request: New documents (initialize, the wordcraft_native/gridcraft_native document status) need open; acquire, handoff and new agent tasks need open. A refusal is 503 with reason app_disabled or app_draining. The metadata stream stops renewing a lease when the mode no longer allows it, so the editor keeps its draft. Turning off never changes stored documents. An operator can still configure a native development profile (craft_native.enabled: true or CREWSHIP_CRAFT_NATIVE_ENABLED=true with the profile variables below). It replaces the downloaded component of that application, runs only on Linux, and fixes the setting (server_config). It requires a Linux boot-aware clock. A qualified Word profile is advertised as wordcraft_native by the authenticated workspace document status endpoint. New Doc then creates a fresh empty personal Page, initializes its native Word model and releases the initial lease before opening the full editor. An ambiguous initialization is never blindly replayed. Native format metadata on Page reads reopens an existing native Page through that same fresh admission. A lost creation-release response cannot erase or reinitialize its confirmed data. This does not convert existing documents or qualify the complete editor acceptance. Agent proposals are described under “Agent proposals” below. Availability requires a readable Page with an explicit craft_native_documents profile. Other profiles, inaccessible Pages and absent Pages report disabled. Ordinary authentication and workspace membership remain required.

Routes

Authentication: ordinary human authentication and current workspace/Page access are required. Request headers: Authorization or a session cookie, plus X-Craft-Session after admission. Query parameters: workspace_id. The slug path parameter accepts a slug or @<page-id>. The private header never travels to an editor frame. Responses are JSON, except the explicitly documented SSE. Statuses: 401 for missing authentication, 403 for denied current authority, 400 for malformed requests, 409 for competing/stale custody, and 503 when a mutation is disabled or unavailable. A generic denial does not prove read loss. Except availability, agent task controls, initialization and initial session creation, send X-Craft-Session as well as ordinary authentication. Session creation returns session_token, the distinct public session_id, application_id, and page_id. Keep the token only in the mounted host closure, never in editor messages, local drafts, URLs or public DTOs. The editor host and its lease controls share one admission; separate admissions represent separate connections even for the same human. A reply lost during an outage longer than the lease stays provable: reconcile checks Page write rights and that the caller is the original admission, not that its lease is still live. When the editor loses write, it reconciles the unconfirmed attempt once before retiring; a proven commit and an identical fresh capture leave no recovery draft, anything else stays offered for comparison. The expired generation still cannot save. Every save carries a fresh opaque attemptId (8–64 of [A-Za-z0-9_-]), and reconcile sends the lost attempt’s id. When version base+1 is not exactly that attempt, reconcile records the attempt in craft_native_retired_attempts inside the same writer-fenced transaction and answers 410: it did not commit and never will. A save of a retired attempt is refused (409) inside its own publication transaction, so a request still validating when reconcile answered cannot publish afterwards (#4899). Only the current live lease holder (that session and generation) can retire, because only it could still publish the attempt; every retirement first deletes rows whose lease is no longer live (a lapsed generation can never become live again) or whose base version is older than the current document head (the base-version fence permanently refuses those saves). A page keeps at most 8 rows. Without an attemptId, without the live lease, or at that limit nothing is recorded and reconcile answers 409: the outcome stays unconfirmed, the editor sends nothing and may ask again later. After a 410 the editor resumes with a fresh capture at the same base and always sends it: it never treats bytes equal to its old baseline as already saved, because that baseline is no longer proof of the head. The server’s base fence then decides between a new version, an unchanged answer and a visible conflict with the draft kept. The editor runs the same reconcile by itself after 2, 10 and 30 seconds and then every minute until the outcome is known, when the browser reports it is back online, and on “Ověřit výsledek”, which also returns the keyboard to the document. A reconcile resends nothing. Restore answers 404 to the lease holder when the target does not exist. The snapshot message binds the session, so crewship craft connect can reconcile only on the connection that made the save; the admission token never leaves that process. From a new process the original snapshot fails the local session check, a rewritten one stays unconfirmed (409), and a new holder’s 410 covers only its own session’s attempts. Recovery after losing the process reads base+1 from the version history and compares its hash and author with the local candidate. Renew/release require {generation: "1"}. Handoff requires {generation: "1", request_id: "public-request-id"}. Other lease mutations use {}. Generations are canonical positive decimal strings, never JavaScript numbers. Before the first acquisition, the status generation is null. A lease status has holder: {actor_id, display_name, kind} | null, mine, generation, expires_at, can_write, can_edit, acquired_at, handoff_requested_kind, handoff_requested_by, and can_stop_agent. An agent holder also has public agent_id and run_id; its kind is agent. Requests are an array of {request_id, actor_id, display_name, kind}, visible to the holder. Current editor admissions are human-only; an agent uses the separate confirmed task binding below. acquired_at is presently null: the lease does not persist acquisition wall time, and renewal time must not impersonate it. Competing acquisition and stale generations return 409. A handoff advances the server generation; a late renew or write from the old connection is rejected. Neither a display name nor a public request ID establishes write authority.

Native editor startup authority and deadlines

A fresh admission starts its authority/lease metadata stream before document fetch and iframe import. While a frame bootstraps, lease events update its advisory view without starting concurrent reader refreshes; one catch-up refresh runs afterwards if needed. A delayed lease HTTP response cannot replace a newer streamed lease observation, although successful current-admission proof is still mandatory. If an in-flight lease read overlaps a metadata observation, write custody must be confirmed by both; neither source’s grant overrides the other’s denial. For the same confirmed custody, the fresh HTTP expiry is used rather than an already expired queued event. Await continuations do not republish an older proof over subsequent metadata. Read retirement removes the frame and revokes the held private journal. An open journal purges its scope; a closed journal may erase only its own persisted row, and a pure viewer has no private journal to purge. A terminal monitor failure, including invalid lease metadata, is uncertainty, not an authoritative Read purge. It hides content and recovery controls before awaiting capture, retaining the exact uncaptured frame/journal if capture fails. Engine failure does not exempt the remaining admission monitor from this retirement. Explicit Read retirement still revokes the held journal. Temporary metadata transport retries remain bounded, admission-specific and mutation-free; they are not Read revocations. Write or other admission retirement during bootstrap ends that attempt without automatically reopening or acquiring another session. In the save lifecycle (lib/mooring/single-editor-save.ts), read loss is terminal: a save or reconcile refused with read_revoked, or the host’s revokeRead(), stops the lifecycle with the state revoked (never stopped, which an ordinary close or identity retirement reports) and requests a journal purge. Once the refusal has been received, it wins over the write fence: a write retirement still pending then reports no capture, and a pending Ctrl+S or flush rejects with read_revoked. A refusal that the write retirement aborts before it settles is not seen; only a later reconcile or the host can then purge. The shipped native client maps every 401/403 to write_revoked and never raises read_revoked; in production, read loss reaches the editor only from the host. The frame is hidden and inert until the native snapshot/open/snapshot identity barrier completes and a fresh lease read verifies the original admission’s current credential, Page rights and authority epoch. Cancellation, a replaced admission or changed person/Page/workspace cannot reveal an old completion. Reader refresh results (including failures) also belong to their original runtime: a pending old read cannot stop a successor writer, acquisition or capture, even when the admission client has not changed. When a retired writer opens a fresh read admission, its previous frame and recovery/comparison controls stay hidden until that admission is verified; a refused admission retains confirmed rows without exposing them or reviving callbacks from the old session. If capturing a retired writer fails, its exact native frame and journal remain held, hidden and inert while reading is unconfirmed. A fresh monitored read admission may reveal that same frozen buffer and enable a local download; it never imports a new document, acquires custody or revives the old writer. Failure hides it again and offers an explicit bounded read recheck. Auth suspension is terminal for this mount, including during capture or read reopen; only a new authorized mount can proceed. Explicit read retirement still removes the frame and revokes the held journal. No copy is downloaded after its read authority or scope has changed. Acquiring write still requires the separate current lease/generation checks, confirmed native access change and draft custody; startup never acknowledges a save. A disconnected status claims a retained draft only after a confirmed local capture, not merely because bootstrap once completed. Fresh admission/document loading and acquisition have a 60-second overall deadline. Handoff applies that deadline from the moment the previous writer confirmed its final save/capture and retired, so it covers the transfer request, its proof and the reader mount; it never times out a live writer’s uncaptured buffer. How long a holder may leave a request unanswered has no deadline: custody moves only by the holder’s choice, loss of connection, revocation or closing. Each new frame has a 50-second overall deadline, including its final authority read; inside it, the frame has 20 seconds for verified read-only ready and each of the at most three import RPCs keeps its 10-second deadline, including snapshot hashing. The connection deadline ends at ready instead of overlapping the finite import sequence. Neither metadata events nor progress reset these caps. A timeout advises explicit reload for a fresh attempt, without replaying an import, mutation or save. Existing captured drafts are not erased on a generic timeout or disconnection; only proven read loss authorizes their purge. Proof is a session_retired event with draft: "purge", sent only while the credential is current, or 403 draft_read_revoked from draft-key with operation: "check", which the host requests after a refused admission and each entry requests whenever it does not open the editor. Losing Edit is not read loss, down to Comment: Comment keeps the shared version, so a refused admission alone erases nothing. An ended credential retires the stream with draft: "retain". Stages and bounds: Private browser draft recovery.

Shared previews and edit capability

can_edit is current original-credential and Page edit authority, independent of whether someone else holds the lease. can_write additionally requires this connection to hold custody. Observers receive no takeover, handoff, publish or agent controls. An older response without can_edit: true grants no UI edit capability. Server mutations continue to recheck the live authority and lease. GET /api/v1/pages/{slug}/craft/preview uses ordinary human authentication and workspace_id, without a Craft admission. It accepts canonical Read or Comment visibility and returns only the current shared native version: version, base64 bytes, applicationId, enginePin, editorBundle and editorURL. The live credential, Page rights, captured authority epochs and immutable native profile are checked in the same protected transaction as the shared pin. No private latest version, history, quota, admission or custody metadata is returned. A missing shared pin returns 404, denied access 403 and disabled/unqualified profiles 503; there is no fallback to the latest bytes. preview_enabled in availability advertises this separately from observer admission. The default editor entry requires both enabled === true and can_edit === true before mounting the editor host. can_edit checks current original-credential and Page write authority in the same transaction as the other availability fields. Read and Comment instead mount the shared preview only when preview_enabled === true, without opening a Craft session; missing or truthy nonboolean edit capabilities grant no host access.

Read and Comment reach only the shared version

The shared preview is the only native content a Read or Comment grantee reaches. Everything else needs private read: current Edit authority, or the workspace OWNER/ADMIN read rule, which on a private Page is wider than Edit and stays audited. That covers GET .../craft/document, GET .../craft/document/versions, GET .../craft/document/versions/{version} (the shared version included: its route is the preview), GET .../craft/proposals and GET .../craft/proposals/{id}. Private read is checked in the read’s own protected transaction, so an admission that lost Edit stops reading at once. A refusal is 403, the status the preview uses for denied access, and it comes before the craft_version_history flag answers. The pages:write token scope is not required: a read-only token of a person with Edit keeps private read. Explicit Read still opens an observer admission, and Comment alone does not. That admission carries custody status only: GET .../craft/lease, GET .../craft/events and POST .../craft/sessions/reauthorize. Lease status names the holder and states the generation, expiry and the reader’s own can_edit and can_write, both false; it carries no version, history or bytes. Handoff requests are only listed for the holder. availability.enabled keeps meaning “may open an admission”, which is why the default editor entry also requires can_edit.

Revocation of a shared preview (push)

The preview has no event stream of its own. When a grant on a native Craft page is added or removed (PUT/DELETE /api/v1/pages/{slug}/grants), the server, after the grant transaction commits, sends one page.access_recheck frame with the payload { "page_id": "<id>" } to each person the changed grant names: the user, the members of a named crew, or the workspace members for a workspace grant (agents are not viewers). It travels on that person’s own identity channel user:{userId}, which only that person may subscribe to, so nobody receives a signal about another user’s rights or about a page that is not theirs. The frame carries no content, no level and no names; it only means “re-check”. An open preview that receives a frame for its own page_id (and on realtime.reconnected) immediately repeats the ordinary craft/preview read. On 403/404 or any failed read it removes the frame, closes the editor port and shows “Sdílený náhled není dostupný”. A frame for another page is ignored. If a read is already in flight, one more read runs after it, so a result that began before the commit cannot keep the content. The frames are sent after the commit, from a bounded background goroutine in batches, so a large crew or workspace does not slow the grant response; a failure is logged and never changes the response. Suspended and restricted accounts are sent no frame. Removing a member from the workspace or changing their role (DELETE/PATCH on the workspace member) sends that member alone a generic page.access_recheck with {}, meaning “re-check every open shared preview”. The 30 s timer and the focus/online rereads stay as the fallback for every change that sends no frame (ownership transfer, removal by an instance administrator or by leaving, a missed frame). The frame cannot grant or reveal anything: the answer always comes from the server’s own authorisation of the read. PRD row A.craft.read-revoked expects the removal within about 5 s. POST /api/v1/pages/{slug}/craft/document/share requires ordinary human auth and X-Craft-Session. Its strict JSON request contains workspaceId, pageId, applicationId, enginePin, sessionId, generation, baseVersion and targetVersion (canonical positive decimal strings for generations/versions). It publishes an existing committed version with current write authority, the current lease generation and latest-version CAS inside the same transaction. Only the Page’s owner may publish (PRD §5): the transaction also checks that the caller’s original credential is pages.owner_user_id, and anyone else gets 403 only the page owner may share a version, whatever their Edit grant, folder ACL or workspace role. A crew-owned Page names no person, so nobody can publish it until a person owns it again. The lease response reports this as can_share. The same transaction records the original human actor, selected version and Page in the authorization audit and journal. Unsupported or failed audit writes roll back publication. It cannot create content or grant access. Rejected publication preserves both content and the prior shared pin. Success returns {version, durability:"node", issuedBy:"crewship", phase:"shared"}; it is a preview publication receipt, not acknowledgement of an unsaved native buffer. Clients must first confirm the exact current native snapshot and must not retry an ambiguous publication.

Authorization and lifetime

Every operation revalidates the original current human credential, workspace, Page rights and captured authority epochs. Lease mutations also recheck current authority after SQL publication. Runtime tokens are hashed in memory; a process restart invalidates them. Durable lease generations and restore fences prevent old custody from being revived by backup replacement. The SSE connection renews only its own authorized current holder every five seconds, with a fifteen-second lease. Browser focus does not control custody. No document bytes travel through this stream. lease events contain advisory status; session_retired stops delivery and explicitly says draft: "retain" or draft: "purge". Generic 401/403 responses alone do not prove read loss and must not trigger local erasure. An admission captures the author-relevant generations of its own human and its own Page (mooring_authority_generations user and page rows, plus the person’s row for this Page in mooring_page_grant_generations, all installed at admission if absent). Any change whose recipients include that person or that Page (their grants on this Page, crew or workspace membership, folder ACL, account status, the Page’s owner, audience, folder or panels) retires the old admission, including a revoke-then-restore cycle. A grant on another Page moves only that Page’s grant generations, so sharing or unsharing another Page, even with everyone or with the editor’s crew, does not retire the session (#5123). The broad workspace delivery epoch (workspaces.authority_revision) is not part of this fence: creating another Page, changing a grant for someone else on another Page or adding a member does not retire a live editor. Current Page rights, membership revision and the original credential are still checked live on every operation and stream heartbeat. A fresh read admission is separate from reacquisition. session_retired carries reason: "write_revoked" (with draft: "retain") only when, at retirement, the same original credential still passes the Page read check and the Page write check is explicitly denied. Every other retirement keeps reason: "authority_changed". The reason is advisory text for the person; it authorizes nothing (#4348). The retired admission, its token and its lease generation stay dead: every write path (save, import, restore, reconcile, share, lease mutations, proposal acceptance) refuses it, and a later re-grant does not revive it. On write_revoked the browser retires local writing exactly as on lease loss (input frozen, the unsaved buffer captured to the private journal, never sent), closes the old client and requests one fresh admission, which the server grants only after a fresh read check. With a confirmed capture, a person who keeps private read (an administrator on a private Page) reads the current version, sees the same read-only editor a non-holder sees and is offered the captured draft for compare or copy. A Read grantee is refused the private read (403 on an otherwise valid admission): the frame is removed and the draft stays sealed and offered to nobody, because a shared version gives no right to unpublished content; that admission keeps the event stream open for lease status only. It returns only if private read does. Server comparisons and proposals leave the page and its state as soon as write is retired, a request still pending then is discarded, and a comparison needs private read again before it asks the server. If the admission retires or the stream drops without a read-loss event, the host opens a fresh one, so a later read loss still reaches it; a reload opens the shared preview. With unconfirmed capture the admission only verifies and monitors reading of the same frozen buffer; it never imports over it or revives its writer, and a refused read keeps that buffer hidden. An uncaptured buffer may use an explicit bounded read recheck, not a background retry loop. Any other refused fresh admission hides content without purging confirmed rows and is not retried; a privacy retirement (sign-out, another person) still erases the draft and the recovery and comparison material. A later read loss on the fresh admission stops delivery like any other (draft: "purge"). That purge also erases the draft row the retired write journal persisted, even though that journal is already closed: a closed handle still refuses a Page-wide purge (it must never erase a fresh mount’s draft), so it deletes exactly its own row by its full key (eraseOwnRow). Work still in flight from the replaced admission, such as a pending document read, is ignored when it completes and never stops the fresh read-only view. Comment-only access is not native read access, so an Edit to Comment downgrade gets no fresh admission, but it is not read loss: the draft stays and the editor keeps asking draft-key about the Page. Original expired or revoked credentials never regain an old admission. Explicit reauthorization accepts only a separately authenticated newer expiry for the same still-live identity and epoch. It never extends an already exhausted boot-time cap or adopts another source identity. The browser obtains a fresh cookie before initial admission and proactively refreshes it while the document remains mounted. A successful same-identity reauthorization replaces the metadata GET with a newly authenticated connection, retaining the private admission, lease generation, editor and local journal. The previous HTTP request still expires at its original JWT horizon. A short, bounded retry of a 409 metadata GET only waits for the previous connection to close; saves, custody mutations and ambiguous outcomes are never replayed. Bearer credentials do not enter the cookie-refresh flow. A transport reset, clean SSE EOF or temporary HTTP failure reconnects only metadata after current same-admission reauthorization. At most eight recovery attempts back off from 100 ms to five seconds. A 409 while the server still holds an old stream after transport loss uses the same exponential budget, allowing several seconds for cleanup. Planned credential rotation keeps its separate short handoff grace. A valid lease on a stream shorter than five seconds does not reset the recovery budget. Invalid metadata, authorization refusal and explicit session retirement are terminal. Exhaustion uses the existing draft-retaining disconnection path. A periodic or recovery reauthorization has a 30-second deadline, the interval between attempts; missing it counts as a failed attempt. Closing the client cancels both the stream and a coalesced periodic/recovery authorization. No save, custody mutation or new admission is replayed by this recovery.

Whole-document configuration and receipts

craft_native.editor_bundle_dir names the operator-owned bundle directory. The existing editor-origin handler verifies each profile digest against its manifest before serving it under /_editor/b/<digest>/index.html, or under <configured-editor-origin>/b/<digest>/index.html when an existing dedicated editor origin is configured. The profile URL must match that exact mount. Native-only mode activates no legacy editor or transport. When a legacy mount is also active, both must explicitly name the same directory; its existing allowlist order is preserved and native pins are appended. Incompatible configuration fails closed. Native bundle requests carrying credentials are refused; the opaque sandbox and credentialless frame policy remain unchanged. craft_native.profiles is a trusted startup map keyed by application. Each profile has application, engine_revision (40 lower-hex), cli_path (absolute operator-owned executable), cli_sha256, editor_bundle (both 64 lower-hex), and editor_url. The operator must qualify the exact source, executable and WASM bundle together. Startup rejects malformed profiles or a different CLI hash. Each validation invokes the real NativeCLIValidator, hashes its opened file descriptor again, and rejects repair warnings or lossy roundtrips. Requests never choose an executable, validator callback or profile. A missing application profile returns 503. Availability never converts an old Page into this profile. Initialization accepts application_id: "wordcraft" or "gridcraft" and base64 bytes; the selected application must have an operator-qualified profile. It requires an existing empty personal Page owned by the authenticated human, current folder policy and live membership. Crew-owned Pages, existing content, read-only credentials, reinitialization and client-supplied pins are refused. The server captures immutable original authority epochs, validates with its qualified CLI, and commits profile, initial lease and version1 atomically with final authorization. It publishes the private admission only after commit; validation or final authorization failure leaves no partial native state. Ordinary Page creation remains separate. The native-only operator environment overrides are CREWSHIP_CRAFT_NATIVE_EDITOR_BUNDLE_DIR, CREWSHIP_CRAFT_NATIVE_WORD_ENGINE_REVISION, CREWSHIP_CRAFT_NATIVE_WORD_CLI_PATH, CREWSHIP_CRAFT_NATIVE_WORD_CLI_SHA256, CREWSHIP_CRAFT_NATIVE_WORD_EDITOR_BUNDLE and CREWSHIP_CRAFT_NATIVE_WORD_EDITOR_URL. The corresponding Sheet overrides are CREWSHIP_CRAFT_NATIVE_GRID_ENGINE_REVISION, CREWSHIP_CRAFT_NATIVE_GRID_CLI_PATH, CREWSHIP_CRAFT_NATIVE_GRID_CLI_SHA256, CREWSHIP_CRAFT_NATIVE_GRID_EDITOR_BUNDLE and CREWSHIP_CRAFT_NATIVE_GRID_EDITOR_URL. These override corresponding YAML fields without enabling the product flag or any legacy profile. Explicit empty values remain empty and fail qualification; every startup/manifest/hash check still applies. Save and import accept workspaceId, pageId, applicationId, enginePin, public sessionId, generation, baseVersion, opaque editorRevision, contentHash in sha256:<hex> form, and complete native bytes as base64. Versions and generations are canonical positive int64 decimal strings. Native bytes are bounded to 32 MiB; the encoded JSON body is bounded to 45 MiB. The lease and current original credential/Page rights are checked in publication transactions, and pins must match the Page’s immutable native profile. No HTML or partial patch stands in for the native model. A snapshot receipt repeats the exact scope/session/generation/revision/hash and base binding, gives the exact successor version, matching candidateHash, issuedBy: "crewship", durability: "node" and phase: "committed". Only this receipt acknowledges Saved. Reconcile sends the original attempt metadata; it may return that same pinned commit after another version exists. A lost ACK does not authorize replaying a save. A repeat never writes (#5075). These are two separate checks on the server:
  • Attempt idempotence. A save whose base+1 is already this same identified attempt (its attemptId, revision, actor and bytes) gets that original receipt back, even when the head has moved on. The snapshot message of the committed version stores the attemptId, because retirement rows are pruned. Another attempt with the same revision and bytes is not a repeat: it gets the 409, and its reconcile answers 410 or stays unconfirmed. A version with the same revision and bytes but no stored id (older than this rule, or saved without one) proves neither a named commit nor its absence: a named reconcile answers 409 and retires nothing, and a named repeat gets the 409. Only a reconcile that names no attempt, which asks about the snapshot, is answered from revision and hash. A client caught mid-save across the upgrade therefore stays unconfirmed until it reloads; it writes nothing.
  • Content equality. A fresh attempt whose base is the head and whose bytes equal the head creates no version and charges no quota. The receipt has unchanged: true and version equal to baseVersion; lease, write rights, retired-attempt and hash checks all run first. Equality with an older version does not count, and another attempt at an older base stays a 409.
A client from before this change rejects version equal to baseVersion as an invalid receipt and shows the result as unconfirmed until reload; it cannot create a duplicate. The agent tool gets the same unchanged receipt for a change that leaves the document as it is. Ctrl+S decides in the browser first. With content and native state equal to the confirmed baseline at the current base it sends no save: it retires the local recovery row and acknowledges the engine with a locally built unchanged confirmation for that exact snapshot, at the confirmed version. The editor port matches it to the snapshot’s own proof and hash, and accepts it only at or above the last acknowledged version. An untouched rendition of the opened document counts as that version. Equal bytes with another native state still go to the server, which decides. While a save is unconfirmed, Ctrl+S first runs one reconcile: a proven commit becomes the baseline and is judged as above, a 410 lets it send a fresh attempt under the base fence, and anything else fails the press with nothing sent. A failed row retirement fails the press and keeps the row; the editor retries once and then delivers the owed confirmation. Restore supplies the current binding and targetVersion, producing a new version rather than replacing history. Read returns version, base64 bytes, applicationId, enginePin, editorBundle, editorURL and quota (usedBytes, limitBytes, warning, level, retention). Quota byte counters are strings. level is none, near (from 80%), critical (from 95%) or full (at the limit) so the editor can warn before saving fails. Quota exhaustion returns 413; conflict or stale custody 409; invalid native data 422. Existing versions remain available. Generic denied writes retain the local draft; only proven read loss requires its erasure.

Native agent tasks

craft_native.agents_enabled or CREWSHIP_CRAFT_NATIVE_AGENTS_ENABLED=true separately enables this pilot. Both native documents and agent tasks are off by default. Enabling the human editor alone grants no agent access. The operator must configure the qualified original CLI and the supported private Claude Code runtime with its current sidecar. Unsupported or unavailable runtimes refuse admission before accepting work. An authenticated human with current Page write access may send POST /api/v1/pages/{slug}/craft/tasks?workspace_id=… with exactly {chat_id, message_id, whole_document: true}. The message must be that human’s persisted original user message in the actual registered agent’s chat, saved as a native Craft instruction in a private chat that human created: such a message started no turn. An ordinary turn or a group chat message is refused, because the first has already reached agent logs and the journal and the second is shown to people without Page access. The host captures original credential/authority epochs, the whole-document consent and immutable work input. A confirmed original provider exec and matching current work attempt are required before the actual run credential can access the native document. An ordinary run, a database row, an old grant, or a claimed actor/mandate is insufficient. Repeating the same message/Page/agent cannot launch another task; a new task needs a new original message and consent. The normal human CLI uses the same explicit consent, Page authority and instruction send:
craft task start requires a true --whole-document; it never supplies consent by default. These human commands do not use the native editor session or agent credentials. Stop reports the actual cancellation/termination fields, rather than assuming the process has stopped. Mutations are not replayed on failure. The native editor offers the same start in the browser (“Upravit agentem”) without any further endpoint. It appears only when /craft/availability answers a literal true for agents_enabled and can_edit, and never while the host is read-only, retired, blocked on an uncaptured draft or retiring its write. The person picks an agent, writes the instruction and ticks “Agent smí upravit celý dokument”; nothing is created before that deliberate action and consent is never remembered for a later start. Starting opens a new private chat with that agent (POST /api/v1/agents/{agentId}/chats) and sends the instruction with the normal chat client, marked metadata.native_craft_instruction: true. That literal flag persists and echoes the message but starts no chat turn, so the instruction reaches no ordinary run, agent log or other member’s diagnostics; the flag cannot be combined with Page context, an ask form or a document intent. Once the echo has named the persisted message_id and the send has settled with done, the editor sends the task request above exactly once. An end of the attempt invalidates it synchronously, so a host that retires in the same render neither sends nor posts. Any refusal or lost reply is reported and not repeated, because a message can launch only one task. The chat itself stays ordinary: a later message the person writes there starts a normal turn, whose history leaves the instruction out so the agent cannot repeat it into its logs. The instruction is also left out of the Keeper judge’s prompt, the operator model, conversation search and shared chats. The transcript alone keeps it, for the chat’s audience. Whatever copies a transcript into a new send keeps the classification and stops: chat retry, retry, copy-prompt, the browser’s Regenerate and Edit and resend refuse a marked message, and recap, the history --prompts preview, the export bundle’s prompt.md and the “from this conversation” action forms leave it out. Offset and limit on the default read count only the messages it returns. A person who retypes the text sends an ordinary turn. The outcome appears in the task status panel, and craft_task_id is added to the URL. A document a person holds is started the same way: admission does not consult the lease, the run’s craft_acquire gets 409 and it records a proposal, which the status names. The control refuses to start while an agent holds the lease, because that run’s refusal has no defined proposal route. The accepted response is {work_id, status: "queued"}, HTTP 202. It is not proof of execution or a save. The same original human can inspect GET /api/v1/pages/{slug}/craft/tasks/{workID}?workspace_id=… under fresh current Page write rights. This read-only projection contains actual bounded tool outcomes and last_committed_receipt, never prompt or command bodies, native document bytes or credentials. outcome is saved once the run has a committed receipt, or proposal with proposal_id when it recorded a proposal instead; it is absent while the run has neither. The last receipt is explicitly historical; an uncertain or in-flight later attempt is not Saved. Append craft_task_id=<work_id> to the normal native editor URL to inspect this task in the editor. That selector is not authority and never starts or resumes a run. The task’s original agent calls the fixed sidecar MCP tools craft_read, craft_acquire, craft_renew, craft_apply and craft_release. They forward the actual run credential and crew IPC identity to the fixed private native-task RPC endpoint. Requests cannot select a Page, actor, run, executable or profile. The host binds every call to the one captured task and rechecks current original rights and epochs at the final SQL writer. Acquisition uses the same EditLease as the human editor. A human holder returns 409 with holder_kind: "human"; no engine mutation or version is published. Generations and base versions remain canonical positive decimal strings. Apply requires the current generation, exact base_version, a new bounded request_id and structured commands. The original engine performs the edit; the existing NativeStore validates the complete returned native document and publishes through the same lease/CAS/VersionStore path. There is one publication attempt. An exact repeated request only reconciles its original snapshot; it never reruns the engine. A changed packet with the same ID conflicts, and a historical receipt cannot clear another attempt’s uncertainty or downgrade the last confirmed version. The initial command surface is deliberately narrow: Word supports caret.docStart, caret.docEnd and plaintext text.insert; Sheet supports scalar/literal cell.set. Word text and Sheet cell.get provide bounded original-engine reads. File/system commands and formulas supplied through this initial agent tool are refused. This does not restrict the human editor’s original feature surface. Executables are operator-pinned and rehashed before executing the same file descriptor; document paths are private host-generated files, with no shell interpretation or inherited host credentials. The normal fifteen-second lease is unchanged. A live authenticated apply renews its exact lease every five seconds only while that bounded request remains alive. Between calls the agent must explicitly renew. The holder is agent:<actual-agent-id> with public session correlation native-agent:<actual-run-id>; the immutable version retains that actor and run alongside the captured work/run provenance. Detailed original consent auditing also depends on the existing work/run ledger retention. No human session is borrowed and no separate version ledger is introduced. Only the original human, with fresh current Page write rights, can send POST /api/v1/pages/{slug}/craft/tasks/cancel with {}. The host retires only that registered agent’s exact lease and permanently closes the grant under the SQL writer before acknowledging. It cannot force a human holder. The response reports grant_revoked, lease_retired, cancel_requested, stopped and stop_pending; physical process termination is not assumed from cancellation. The human editor exposes Stop only when can_stop_agent is true. A stopped process is successful only with retired custody and either a qualified actual Node receipt (native_outcome: "saved") or a proposal this run recorded (native_outcome: "proposal", native_proposal_id); a run with neither fails with “native agent finished without a committed native receipt”, and uncertain outcomes remain distinct. This pilot retains at most 128 captured task entries per process and at most 64 apply IDs per task. It refuses further work instead of evicting uncertain authority. The private status timeline retains the last 64 actual completed tool responses and is explicitly current-host-only; a restart cannot reconstruct a grant or its timeline from SQL. Durable native versions and original receipt metadata remain in VersionStore. Existing documents are never migrated.

Current implementation boundary

The backend has direct-handler and real-Router two-human regression coverage. Whole-document routes require an already explicit native Page and a trusted qualified profile; only the explicit owner-only native initializer may create that profile on an existing empty Page. Page creation, full editor mounting, proposals and agent execution, one-hour browser acceptance and 2,000 changing saves are separate integration capabilities; coverage of one does not establish the others. New Doc offers Word and Sheet only when their independent wordcraft_native and gridcraft_native status capabilities are literal true. These capabilities advertise live, qualified profiles without enabling the older Docs engine. The owner-only native initializer receives the selected original engine’s canonical blank bytes for a fresh Page. Creation releases only its exact lease generation; an ambiguous release can reopen the confirmed document with fresh read-only admission, while an ambiguous initialize is never repeated. Existing native Pages retain their application descriptor when opened from the Pages list. The native blank Word asset is serialized by the original engine separately from the older checkpoint blank. For explicit qualification, set CRAFT_QUALIFICATION_ENGINE and CRAFT_QUALIFICATION_SHA256 to the original pinned Word executable and hash, then run crewship-heavy go test -tags craft_native_engine_qualification ./internal/mooring -run ^TestNativeCLIValidatorCanonicalWordCreation$. The run must execute and pass, including refusal of the old lossy float promotion. The canonical Sheet asset is the qualified GridCraft CLI’s native output, used without model conversion. Set the same two qualification inputs to the original pinned Grid executable and hash, then run it separately: crewship-heavy go test -tags craft_native_engine_qualification ./internal/mooring -run ^TestNativeCLIValidatorCanonicalSheetCreation$. It must accept the actual creation bytes and reject an authored extension that the engine would discard. Both are qualification-only inputs, not server configuration. These explicit runs fail when inputs are absent; ordinary unit builds exclude them. Default CI is not evidence that either engine executed. The human and agent transport boundaries have targeted API, original-engine, private-process, retirement, cancellation and receipt regressions. Live editor and genuine dispatched-agent acceptance must still qualify the exact release; unit provider/model fixtures are not live agent evidence. Complete visible-editor feature acceptance remains separate work.

Agent proposals

When a person holds the lease, an agent never waits for, steals or retries it. craft_propose runs the same allowlisted original-engine commands over the exact base_version the agent read and stores the complete candidate in craft_native_proposals; the document, its versions and the lease are unchanged. At most 16 proposals per document may be open. Before counting, a new proposal marks every open proposal whose base content differs from the current head stale (decided by system:superseded; rows are kept), because such a proposal can no longer be accepted. A proposal over an older version with identical content stays open, since accept still applies it on the head. The stored idempotency key includes the authenticated run. A different run of the same agent has a separate request namespace and cannot adopt its earlier candidate; the display author remains the agent identity. Apply and propose requests share a limit of 64 admitted change attempts per run; reaching it returns 429 before another engine invocation. Exact retries consume no further slot and never rerun the engine, including after the document advances. Changing commands under the same request identifier conflicts. An unknown save or proposal commit blocks new changes with 503 until that exact request is reconciled. Only its pending candidate is retained for this purpose; completed or definitely refused candidates do not accumulate in memory. All retained proposal content, including rejected, stale and accepted rows, counts toward its own per-document proposal allowance (64 MiB by default, NativeStoreConfig.MaxProposalBytes for trusted host configuration). This allowance is separate from human version history: filling it refuses additional proposals while leaving the human’s available history allowance intact. Accept charges the newly saved version to version history and keeps the candidate in the proposal allowance. Reject never replenishes either retained candidate space or the human’s version history. Accounting uses exact bytes for older rows restored as TEXT and reserves decision metadata up front. Native BLOB lengths use SQLite’s metadata-only length path; Read, View and Save do not scan retained candidate payloads. Insertion and the proposal budget check share the authorized writer transaction; a refusal rolls back the new candidate without deleting any confirmed candidate or version. These are document allowances; aggregate workspace/disk reservations remain a separate storage requirement. A proposal is a successful task outcome: the work item and run succeed with native_outcome: "proposal" and native_proposal_id, and the task status shows outcome: "proposal" with proposal_id. Only a proposal recorded by this run’s own session counts. A different run may reuse a client request_id; it records its own proposal instead of adopting an earlier run’s candidate. A person with an admitted session and private read reviews them (GET .../craft/proposals, candidate bytes at GET .../craft/proposals/{id}) and decides once. Accept is the person’s own fenced save: Page write rights, the immutable profile, this admission’s lease generation, base == latest and the open state are checked and the version and decision commit in one transaction. The editor saves the holder’s state before accepting; versions that only repeat the base bytes do not count as a change, and the proposal applies on the current head. If the document content moved on, the proposal becomes stale and nothing is applied; it is never rebased over changed content. Reject needs write rights but no lease: any co-editor with current Page write rights may reject a proposal while another person holds the lease, and the document is never changed. Reading the list and a candidate uses the same session-bound read gate as the document, so a Read-only admission can review but not decide; Comment-only access is not admitted at all. A proposal id is resolved only within the admitted page and workspace; another page’s id answers 404. The agent cannot decide its own proposal. The bounded proposal review list puts all open proposals before decided history, then orders each group by creation time. Newer decisions cannot hide a pending proposal; the per-document open-proposal cap remains 16 and the list cap 50.