RFC-0003: Native files as artifacts¶
Status: Draft, amended by portalr ADR-0041, lattice ADR-0026 and lattice ADR-0027, and by native files ADR-0010, ADR-0011 and ADR-0012 Author: Alex Nodeland Created: 2026-10-16 Discussion: #248 Siblings:
- artifactr ADR-0003 fixes the two patch kinds. This RFC adds none.
- artifactr ADR-0004 makes every edit name its base version, and rejects pessimistic locks. This RFC keeps the first and narrows the second, for native files only.
- lattice RFC-0002 is portalr, which gains a renderer, previews and check-out for native files.
Summary¶
A new package, artifactr-files, gives artifactr artifact types for files in native formats: Word documents first, then presentations, spreadsheets, PDF, SVG, images, CAD and audio. An artifact of such a type is a small Pydantic manifest that names the file's bytes, by SHA-256, in a blob store. The file is the source of truth; nothing parses it into Pydantic fields.
- Agents edit through a native application run headless in a sandboxed worker, by batches of its own commands. The pilot's application is WordCraft, one of the open-source Rust "craft" apps.
- People check the file out, edit it in the app's unmodified browser build, which the deployment hosts, and check it in.
- Every revision is an ordinary JSON Patch on the manifest that first tests the content digest it was made from.
- A check-out is a lease. While someone holds it, an agent's edit is refused as busy, and the agent plans again after the check-in.
artifactr gains a lease on an artifact, an addition to its workspace API; its patch kinds and its core rules don't change, and lattice-core doesn't change. The pilot is WordCraft and .docx only. This changes artifactr, portalr and stackr as well as adding a package, so it is in lattice's series.
Motivation¶
- Work arrives as files. The documents people and agents share are often a contract in
.docx, a deck in.pptxor a model in.xlsx. artifactr's types hold JSON and Markdown. Today an application either converts the file to Markdown, losing styles, tables, comments and tracked changes, or keeps it outside artifactr, losing revisions, proposals, attribution and the log. - A model should edit the file, not a copy. Converting to Markdown and back is lossy both ways. An app that reads and writes the format natively, and exposes its edits as commands, lets an agent change the real file.
- The craft apps make it plausible. getartcraft.com/apps lists thirteen open-source Rust apps for native formats, each with a browser build. WordCraft has a headless MCP server that edits
.docxby command; the evidence records what was checked.
Design¶
Amended (2026-10-17) by lattice ADR-0027: A native file's app is an integration,
files:<app>, such asfiles:wordcraft. The switch is the sharedIntegrationsport in lattice-core,is_enabled(tenant_id, workspace_id, key), answered by grantr per deployment, tenant and workspace, audited, and set from portalr's admin; artifactr refuses what it governs throughWorkspaces(gate=...), asNotEnabled.
Overview¶
graph LR
person["a person: the app's browser build,<br/>on its own origin, unmodified"] -- "check-out: download<br/>check-in: upload" --> portal["portalr"]
portal --> router["files_router"]
agent["agent tools"] --> pkg["artifactr_files"]
router --> pkg
pkg -- "bytes and commands" --> worker["worker<br/>(sandboxed, no egress)"]
worker -- "new bytes, outline,<br/>text, review" --> pkg
pkg -- "1. put by digest" --> blobs[("blobs")]
pkg -- "2. lease, then EditArtifact:<br/>test old digest, replace" --> ws["Workspace"]
An agent's edit and a person's check-in end the same way: the package puts the bytes, text and review (comments and tracked changes) in the blob store, then commits an EditArtifact through the one write path (ADR-0002). A failure before the commit commits nothing, and what was put is collected.
The package¶
packages/artifactr-files/ holds the types, the Blobs port with in-memory and SQL adapters, the App port with FakeApp and the WordCraft adapter, NativeFileTools (a pydantic-ai toolset), files_router (behind a fastapi extra), and the worker's job runner, with drift-tested schemas for the manifest and the router.
- A separate distribution, as a deliberate departure from ADR-0034. ADR-0034 puts each adapter in an extra of the package whose port it implements. This is not an adapter of an artifactr port. It is a domain extension with its own tables and migrations, a worker program, a router, and a dependency on an external application in alpha. Keeping it out of artifactr leaves artifactr's dependencies and migration chain untouched for every application that doesn't edit native files. relayr is the precedent for a package that builds on artifactr from outside.
- It depends inward only. It imports only names in
__all__ofartifactr,artifactr.coreandartifactr.workspace, pluslattice_core.sql.tenancy; artifactr imports nothing of it. Itstests/test_layering.pyholds it there, as relayr's does. - It lives in the monorepo, so CI runs its tests on every change to artifactr (lattice ADR-0002). Its
artifactr-airequirement is a lower bound, raised by hand. - Its PyPI name is
artifactr-files-ai, with the suffix artifactr's own distribution has; it is imported asartifactr_files. Bothartifactr-filesandartifactr-files-aiwere free on PyPI on 2026-10-16.
Amended (2026-10-17) by lattice ADR-0026: the package is not a distribution of its own. Before its first release,
artifactr-filesbecame artifactr'sfilesextra,artifactr-ai[files], imported asartifactr.files, with its docs a section of artifactr's (native files), and an application turns it on per workspace through the shared Integrations switch. D5 below is decided the other way: an extra of artifactr. What else this section says holds: it depends inward only, on the public API ofartifactr.coreandartifactr.workspace, and the rest of artifactr imports nothing of it; its tables keep their own Alembic chain and version table. Itsartifactr-aifloor is gone, andpackages/artifactr-files/andartifactr_filesin this RFC now meanartifactr.filesinpackages/artifactr/, where the layer once namedartifactr_files.filesisartifactr.files.native.
The artifact¶
class Blob(BaseModel):
sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
size: int = Field(ge=0)
media_type: str
class Provenance(BaseModel): # how the content was made; who made it is the revision's actor
source: Literal["upload", "check_in", "commands"]
app: str | None = None # "wordcraft"
app_version: str | None = None
build: str | None = None # the worker image's digest
base: str | None = None # the content digest it was made from; None for a new file
commands: Blob | None = None # the batch, as canonical JSON
class NativeFile(Artifact, abstract=True):
formats: ClassVar[frozenset[str]]
write_policy: ClassVar[WritePolicy] = "propose"
name: str
content: Blob
fingerprint: str # the digest of the content, normalized (below)
text: Blob | None = None # plain text, for diffs and the agent
review: Blob | None = None # comments and tracked changes, as JSON
outline: str = Field("", max_length=4_000)
pages: int | None = None
provenance: Provenance # render_for_agent: the name, the pages if known, the outline
class WordDocument(NativeFile): # "word_document", for .docx
formats = frozenset({DOCX})
- Bytes never enter the log. Every revision stores its resulting data (ADR-0004), events carry it, WebSocket replays send it, and relayr bridges it into reflexr. A manifest is about a kilobyte plus the outline; a
.docxis often megabytes. render_for_agentis synchronous and does no IO, so the agent sees, by default, the outline the worker wrote when the revision was made.- The fingerprint makes a no-op save no revision. WordCraft stamps
docProps/core.xmlwith the time on every save (see the evidence). The fingerprint is the digest of the archive's other parts, computed by the worker. A check-in whose fingerprint equals the head's commits nothing. - Provenance is a claim.
edit_artifactover REST or MCP can write anything, so only the revision's actor is trusted, and nothing authorizes by provenance. - Write policies bind agents only:
proposemakes an agent's edit a proposal; a person's check-in commits directly.
Edits: JSON Patch with a test¶
A third patch kind, a "command batch", was the obvious alternative. It can't work: core.commit is pure (ADR-0001), and a batch's result exists only after a native app has run, for seconds, outside the transaction that holds the workspace's row lock (ADR-0021). The package builds the revision as JSON Patch instead:
[
{"op": "test", "path": "/content/sha256", "value": "<the digest it was made from>"},
{"op": "replace", "path": "/content", "value": {"sha256": "...", "size": 48213, "media_type": "..."}},
{"op": "replace", "path": "/provenance", "value": {"source": "commands", "base": "...", "commands": {...}}},
// and replaces of /fingerprint, /text, /review and /outline
]
- A stale direct edit fails first on
base_version, asVersionConflict. - The
testmatters when a proposal is accepted. Acceptance re-applies the stored patch to the head as it is then, and checks no version, so without the test a proposal made against an old version would silently replace everything since. With it, accepting a stale proposal fails asPatchFailed, which the evidence shows. - A stale proposal is outdated, never rebased. portalr shows it as outdated, and its author makes a new one, with
base_versionat the head and a test on the head's digest. - Accepting with changes (
RespondToProposal.changes) isn't offered for native kinds. That is portalr's choice, not enforced: artifactr applies such changes to the manifest as it would any edit. - Summaries come from the batch or the check-in. The package passes
summary=, so change notes don't readtest /content/sha256; replace /content.
Check-out: a lease on the artifact¶
lock is the only merge strategy in the pilot. One writer at a time, through a lease on the artifact:
@dataclass(frozen=True)
class ArtifactLease:
artifact_id: ArtifactId
holder: str
expires_at: datetime
class Workspace:
async def lease_artifact(self, artifact_id: ArtifactId, *, holder: str, ttl: timedelta) -> ArtifactLease: ...
async def artifact_lease(self, artifact_id: ArtifactId) -> ArtifactLease | None: ...
async def release_artifact(self, artifact_id: ArtifactId, *, holder: str) -> None: ...
class ArtifactBusy(InvalidState): # exported beside ThreadBusy
lease: ArtifactLease | None # None if the lease ended between the refusal and the read
- In artifactr's
Workspace, as an addition, asclaim_thread's lease is, since a lease taken straight onStoragewould skip the access check and ADR-0062's hiding of restricted artifacts. - Its access checks.
lease_artifactandrelease_artifactaskartifactr:edit_artifactfor the artifact.artifact_leaseis a read, which portalr shows to viewers, so it asksartifactr:read_artifacts. A restricted artifact is answered as a missing one.ACTIONS, the complete catalog, doesn't grow, so grantr's catalog and its drift tests are unchanged. - Its storage. The key is
artifact:<id>, which can't collide withclaim_thread'sthread:<id>.Storagegains one read,lease(scope, key), for the holder and expiry, and artifactr's two storages implement it. A storage an application wrote must add it; the owner accepted that (D6), and the PR is marked breaking ifscripts/breaks.pyreports it. - Its semantics are
acquire_lease's. The same holder acquiring again renews, and that is how a check-out is extended. Another holder may take a lease onceexpires_at <= now.nowis each process's clock, as it already is for thread claims, so the seconds-long leases below assume clocks agree to well under a second.
A check-out is a record in the package, artifactr_files_checkouts: its id, the artifact, the base version and digest, the actor, and the expiry. The lease's holder is checkout:<id>:<actor id>, which names that record, so two tabs are two holders, and a refusal can name who has the file. The check-in reads the base from the record, never from the upload, for its test and its provenance.base.
- A check-out lasts eight hours by default. Its expiry is shown on the artifact, and the person extends it by acquiring again. Nothing renews it in the background, because the browser build can't tell the portal it is still open.
- An agent's edit takes a lease of its own, with the holder
edit:<uuid>, unique per call, for the worker call and the commit. Its ttl is the worker's deadline plus the commit, 45 seconds by default, with no renewal: an edit that outlives its lease is still refused at commit, bybase_versionand the test, if anyone else committed meanwhile. - A busy agent re-plans. If someone else holds the lease, the tool returns
ArtifactBusywith the lease, or none if it ended in the meantime, in which case the agent may try at once. Otherwise it plans again against the new head after the check-in, with the change note ADR-0008 gives it. It never queues a proposal that would be stale on arrival. - A check-in isn't atomic with the lease check. A lease can expire between the check and the commit; the test and
base_versionare the guard. If the check-in is stale, because its lease lapsed and someone committed, it is kept as a proposal against the head:base_versionat the head, and a test on the head's digest. It is the one proposal from a person, and the reviewer sees both versions' pages. - A retried check-in is idempotent. If the head's fingerprint already equals the upload's, the check-in succeeds and makes nothing new.
- The lease is advisory. Core doesn't know about it, so
edit_artifactover REST or MCP isn't stopped. The test makes such a write fail or makes the next check-in stale, never silent.
This narrows ADR-0004's rejection of pessimistic locks (its option C) for native files. ADR-0004 feared that an agent could hold a lock across seconds of model latency, and that locks leak on crashes. Here, an agent never holds a lease across model calls, only across one worker call, and a person's lease expires on its own. Proposals stay the path for review. The decision is recorded in an artifactr ADR, "a lease on an artifact", beside "native files are manifests over JSON Patch".
Blob storage¶
class Blobs(Protocol):
async def put(self, scope: Scope, artifact_id: ArtifactId, data: AsyncIterable[bytes], media_type: str) -> Blob: ...
async def get(self, scope: Scope, artifact_id: ArtifactId, sha256: str) -> bytes | None: ...
async def collect(self, scope: Scope, referenced: Set[tuple[ArtifactId, str]], *, before: datetime) -> int: ...
| Decision | Choice |
|---|---|
| Where the port lives | In the package. Adding methods to Storage for bytes would break every storage an application wrote, and nothing else in artifactr needs bytes yet |
| Key | (tenant, workspace, artifact, sha256), as ADR-0021's keys start. A blob's access is its artifact's: a manifest that names another artifact's digest finds nothing |
| Deduplication | Within one artifact: its revisions share unchanged bytes. Never across tenants, which would tell an uploader that someone else has a file |
| SQL | artifactr_files_blobs, bytea (SQLite BLOB) with storage EXTERNAL, since zipped formats don't compress; the package's own Alembic scripts and version table (ADR-0021); row-level security through lattice_core.sql.tenancy, as artifactr's adapter has it (ADR-0066) |
| Size | 25 MiB a blob by default, configurable to 100 MiB; the router refuses a larger body before buffering it |
| Object store | Deferred. stackr already has an S3 port, with MinIO by default (stackr ADR-0005); an adapter for it is the same port, with isolation by key prefix instead of row-level security. It waits for a deployment that hits the size limit |
| Order of writes | Put first, idempotent by digest, then commit; a put never committed is only garbage. Digests and byte counts are computed on the server as the upload streams to a size-limited spool; a client's digest is never used |
| Collection | put sets last_put, including on a blob that already exists. collect deletes, in one statement, the artifact's blobs with last_put before the cutoff (a day ago by default) that aren't referenced. Referenced means named by any revision's data, archived artifacts included, or by a pending proposal's patch. It runs per tenant, as the application's role, so row-level security holds |
| Missing content | Every surface can commit edit_artifact, so a hand-written patch can name a digest that isn't there. Reads then show "content unavailable", and the tools refuse to edit until the content is replaced |
Renders are a cache with the same key. A page image is keyed by tenant, workspace and artifact, then by the content digest, page, scale and the worker's build. It is never keyed by digest alone, which would let one tenant's cache answer another's.
Every route that serves bytes resolves the artifact first, through Workspace.artifact(...), which asks the access hook and hides restricted artifacts. Only then does it check that the revision names the digest. A hidden artifact is answered exactly as a missing one.
The app port and the worker¶
class App(Protocol):
async def apply(self, source: bytes | None, commands: Sequence[AppCommand], *, deadline: float) -> Applied: ...
async def inspect(self, source: bytes, *, deadline: float) -> Inspection: ... # outline, text, review, pages, fingerprint
async def render(self, source: bytes, page: int, *, scale: float, deadline: float) -> bytes: ... # PNG
async def commands(self, query: str) -> Sequence[CommandSpec]: ...
- Stateless calls over MCP on stdio. WordCraft's
wordcraft-cli mcpspeaks only stdio, and confines file access to a read root and a write root. stdio needs no listening port, which is what makes a sandbox with no network possible. Each call is bytes in and bytes out, so no document stays open between calls. - One adapter per app, since the apps' control channels and MCP tools differ. WordCraft's has two:
Subprocessspawns the CLI locally, for tests and trusted files, and is not a sandbox;Workersends the call to the worker.
The worker treats every file as hostile: a zip bomb, deeply nested XML, an enormous image.
- A service on an internal network (
internal: true) that only the application joins: no route out, no published port. - The application talks to it over HTTP: one request per call, the file as the body and the call as JSON, authenticated by a token the deployment generates. The worker runs one job at a time. It spawns a fresh
wordcraft-cliprocess per job, in an empty temporary directory given as its read and write roots, and wipes the directory afterwards. It scales by adding containers, never by running jobs side by side in one. - Limits on every call: a deadline (30 s for an edit, 10 s for a render by default), and CPU time, address space and file size set by
setrlimit. A limit on the reply's size too. A breach is a typed failure:timeout,crashed,too_largeorrefused. - The container: not root, a read-only root file system, a
tmpfswith a size limit, every capability dropped,no-new-privileges, the default seccomp profile, and memory, CPU and PID limits. No fonts are installed, so its renders depend on the image alone. - stackr owns the policy, because it owns the network, the limits and the image.
What the agent sees¶
| Need | Tool or hook | Budget |
|---|---|---|
| What the file is | render_for_agent: name, pages, outline |
4,000 characters, the capability's max_render_chars |
| Its words | read_native(artifact_id, pages), from the text blob |
8,000 characters a call |
| Its comments and tracked changes | read_review(artifact_id), with each comment's id |
50 items a call |
| Its look | view_page(artifact_id, page), one rendered page |
One page a call; off unless the application turns it on |
| What it can do | find_commands(query), through the app's list_commands |
Ten a call; WordCraft has 617 commands |
| To change it | edit_native(artifact_id, base_version, commands, summary) |
One batch a call |
People: check-out and check-in¶
People edit in WordCraft's browser build, unmodified and as released, served by the deployment. Today it is a standalone page with no host channel: it opens files through a picker, drag and drop or paste, and saves through showSaveFilePicker or a download.
- Check-out. portalr creates the check-out and its lease, downloads the head revision, and opens the hosted editor in a new tab, showing the expiry.
- Editing. The person opens the downloaded file in the editor, edits it, and saves it to disk.
- Check-in. The person uploads the file in portalr. The router streams it, computing its digest and fingerprint. The worker reads its outline, text and review. The router then commits a revision as the person, with
source: "check_in", and releases the lease.
The editor's origin:
- An origin of its own: its own registrable domain, or a host name for which portalr sets no cookies, so the editor never sees a token. Its CSP is
default-src 'self', plus the inline script's hash,'wasm-unsafe-eval'andblob:, withframe-ancestors 'none'. Nothing calls out. - Shared browsers: the editor stores preferences and fonts, not documents, and the check-in page reminds the person that their downloaded copy stays on disk. Hostile files now also open in the browser's sandbox; the server still reads every revision in the worker.
How much an app can talk is a capability of each App adapter, as merge strategy is:
| Level | Meaning | Who has it |
|---|---|---|
| 1. Save only | Bytes when the person saves | WordCraft's browser build: its JavaScript exports only its initializer (verified) |
| 2. Host to app | Commands, with replies | WordCraft headless, over MCP (verified); FilmCraft, EffectCraft and LightCraft through same-page JavaScript APIs; GridCraft, PdfCraft and CADCraft through a ?host=parent bridge offering open, save and dirty only, to a same-origin parent |
| 3. Polled | Changes found by asking | WordCraft's desktop app, through its undo history or a recorded macro (verified in code) |
| 4. Pushed | Changes, selections or commands as they happen | No app: nothing pushes more than dirty |
Apart from WordCraft's, these rows come from research on 2026-10-16, unverified here.
Amended (2026-10-18) by native files ADR-0010: a
?host=parentbridge (GridCraft, PdfCraft, CADCraft) gives open, save and dirty, not commands with replies: it is level 1+,bridged, reached through a shim of lattice's own on the editor's origin, run by hand with GridCraft 0.8.0, PdfCraft 0.8.0 and CADCraft 0.8.0. The JavaScript APIs' level is claimed per app once each has run through the shim.
The pilot stays at level 1. A later phase may embed WordCraft through a host bridge, if one lands upstream and ships unmodified; we neither fork nor contribute. Until an app pushes events, events are polled or synthesized from revisions. A same-origin iframe is a stand-in only for the three apps with JavaScript APIs.
Watching upstream. Today there is no roadmap, spec or shared crate, and requests for pushed events (photocraft#4939, vectorcraft#2188) have no maintainer reply. What would change this: gridcraft#1466 (a full control channel over postMessage), photocraft#2263, or WordCraft's #405 and #1655 (host bridges) merging; a shared craft-embed or craft-control crate; release notes mentioning host=, window. or postMessage.
Next, a streamed session, evaluated in phase 5. The unmodified desktop app runs under a virtual display, in a fixed pool of sandboxed containers, pre-started, one session at a time each (D7), streamed to the browser as noVNC or Kasm do. The agent reaches the live document through the app's control channel, which listens on 127.0.0.1 inside the container, with its key in a file there. The application sends the call to a runner in the session container over the internal network. The runner runs wordcraft-cli mcp --connect inside the container, so the channel and its key never leave it. Events come by polling.
The spike measures the cost per session and how many a host holds; the input-to-pixel delay; the security of the stream (authenticated per session through the portal), the clipboard, and wiping the document at the end; and how large a pool a host holds. Whether the desktop build runs under a virtual display at all is unverified.
The log, feedback and telemetry¶
- No new events. An edit or a check-in is an
artifact_changedlike any other, so relayr bridges it and a reflexr rule can react to it, seeing the manifest, never the bytes. Feedback targets an artifact version, as today (ADR-0028). - Spans for
apply,inspectandrender, carrying the app, version, build, command count, byte counts and outcome, as digests and never content. Metrics: a duration histogram per operation and failures by kind, through ADR-0036's views, with artifactr's tenant attribute.
portalr¶
- A renderer for native kinds in
ui: page previews, the name, size and outline, a download, and the lease's holder and expiry. Itstext(data)is the outline. A revision diff shows the changed text, from the text blob, and the pages whose renders differ, side by side. - Check-out and check-in, with the lease's expiry, extending it, and a stale check-in shown as a proposal with both versions.
- Routes in
files_router, mounted beside artifactr's with the sameresolve_actorand access hook: check out, check in, extend, download a revision, and fetch a page. Each route names an artifact and a revision, never a bare digest.
stackr¶
- A recipe, off by default. The template's
native_filesquestion adds the worker, its internal network, limits and token, and the editor's host name. - One release, two artifacts, one pin. The worker's thin image is built from the release tarball's
wordcraft-cli, and the editor is the same release'swordcraft-webarchive, served as static files by the stack's edge. Both are checked againstSHA256SUMS.txt, and the image is built as stackr ADR-0035 has it and pinned by digest. A check, likecheck-config's, fails CI if the two pins differ. Upstream signs nothing and publishes no SBOM. Phase 3 doesn't wait for it (D8): the checksum and digest are verified in CI, and stackr records the provenance itself, until upstream signs. - Notices and marks. Both carry WordCraft's licence files and
NOTICE. The marks stay as supplied, in the unmodified build only, and nothing of lattice's uses the ArtCraft name or implies its endorsement, as the brand licence requires.
What changes in each package¶
| Package | Changes | Breaking |
|---|---|---|
| artifactr | Workspace.lease_artifact, artifact_lease, release_artifact and ArtifactBusy; Storage.lease; two ADRs and a section of the artifact types guide |
Only for a storage an application wrote, which must add lease |
| artifactr-files | New | |
| portalr | The native renderer, previews, page diffs, check-out and check-in | No |
| stackr | The native_files question, the worker, the editor's hosting, the release pin check |
No |
| lattice-core | Nothing |
Phases¶
| Phase | Deliverable | Exit criteria |
|---|---|---|
| 1. Foundations | artifactr's lease; the package, its types and check-out record, Blobs with memory and SQL adapters, App and FakeApp |
100% coverage; the blob suite on memory, SQLite and PostgreSQL; a two-tenant test reads nothing across tenants; collect keeps every referenced blob; accepting a stale native proposal fails with PatchFailed, and the proposal rebuilt at the head applies; lease tests for renewal, takeover after expiry, and a lease that vanishes before ArtifactBusy reads it |
| 2. WordCraft headless | The Subprocess adapter; inspection, fingerprint and render; a corpus in tests/corpus/docx/, one generated file per feature, plus documents whose licences allow it |
A nightly report compares normalized OOXML after an open and a save, feature by feature (text, styles, run properties, tables, lists, images, headers and footers, footnotes, comments, tracked changes, sections), and publishes a matrix where "supported" is per feature; saving twice is a fixpoint |
| 3. Worker and agent | The worker, stackr's image and recipe, the hostile corpus; NativeFileTools |
Every hostile file ends in a typed failure within its deadline; egress from the worker fails; scripted-model tests for an edit, a proposal, a busy refusal and a conflict |
| 4. Check-out pilot | The hosted editor, portalr's renderer, previews, diffs, check-out and check-in | bdd scenarios for a check-in, a stale check-in, an idempotent retry and a busy agent; the editor's origin serves nothing that calls out |
| 5. Streamed session spike | The evaluation above | A person types in the streamed document while an agent inserts a paragraph, and both see both; a save becomes one revision; p95 input delay under 150 ms on a LAN; a session starts in under five seconds from a pool; no egress, nothing left behind; no Docker socket reachable from any container the application can reach. Then a written recommendation |
Amended (2026-10-18) by native files ADR-0011: no app waits on WordCraft's record. Each app's key is on by default only once it passes its own gate: its release pinned, its fidelity matrix green, its hostile files refused in time, its CLI confined or run by the worker, and its notices read.
FakeApp runs on every pull request; the real worker image runs nightly and in the Acceptance job. Before a second app, WordCraft's matrix has held for two releases, no hostile file has escaped, the second app's licence is read, and its adapter needs no change to App, Blobs or NativeFile.
Future work¶
- Comments that start a turn and the desktop sync bridge go to a follow-up RFC. The bridge commits as the person, with the device in the provenance, and needs a token per person and device that grantr doesn't have. The comment trigger's requirements, from this RFC's review, carry over: who may trigger, loop control, idempotent message ids,
on_behalf_of(grantr ADR-0004), and evaluation in the package's commit path. - Other merge strategies.
rebasesuits apps whose commands name their targets by stable id: batches ordered by the server, refused when stale, replayed by their author. WordCraft's commands act on positions and the selection, so it doesn't qualify.crdtsuits only an app whose own model is a CRDT, and none is. Each waits for an app that needs it, with its own ADR.
Decisions¶
Recommended, for the maintainer to decide.
| # | Question | Recommended | Instead of |
|---|---|---|---|
| D1 | Edits | JSON Patch on a manifest, testing the old digest; the batch as a provenance blob | A third patch kind, which pure core can't apply |
| D2 | Blobs | A port in the package, keyed by tenant, workspace, artifact and digest, on PostgreSQL | Methods on Storage; an object store first; deduplication across artifacts |
| D3 | Concurrency | A lease on the artifact, in Workspace: a person's check-out for hours, an agent's edit for seconds; a busy agent re-plans |
Proposals queued against a lease, stale on arrival; a CRDT; last write wins |
| D4 | People's editing | Check-out and check-in through the unmodified browser build; a streamed native session evaluated next | Reaching into the browser build's internals from a same-origin page |
| D5 | Packaging | A separate distribution, artifactr-files |
An extra of artifactr; a name built on the apps' brand |
Decided by the owner on 2026-10-16:
| # | Question | Decided | Instead of |
|---|---|---|---|
| D6 | Storage.lease() |
Accepted, though it breaks a storage an application wrote; the PR is marked breaking if scripts/breaks.py reports it |
A lease read kept outside Storage |
| D7 | Streamed sessions | A fixed pool of pre-started containers; no Docker socket reachable from the application | A container per session, on demand |
| D8 | Upstream's supply chain | Phase 3 doesn't wait for signed releases: pin by checksum and digest, verify in CI, record provenance ourselves; revisit if upstream signs | Waiting for signed releases |
Drawbacks¶
- A young dependency on the critical path. WordCraft's repository is nine days old, with six releases in seven days, and calls itself alpha. Its own documents disagree on how many commands it has. Commands, tools and output can change in any release.
- One writer at a time, and coarse turns: a check-out lasts hours, and an agent waits for it.
- Check-out is clunky: download, edit in another tab, save, upload. It proves the hosting, the lease and the check-in, not the experience.
- More to run: two stores to keep consistent (put-first and collection make the gap safe, not absent), a worker image to build, pin and patch, and a 40 MB editor to serve.
Alternatives¶
- Convert to Markdown and back, which loses styles, comments, tracked changes and layout every time.
- An embedding API upstream, or our own build of the browser editor over WordCraft's crates. Either would allow an editor inside portalr. The owner's constraint rules both out: no fork, no upstream contribution.
- Reaching into the browser build from a same-origin page, through its WebAssembly memory or its DOM. That depends on internals that change every release, and putting it on portalr's origin would need
wasm-unsafe-evalin portalr's CSP. (Amended by portalr ADR-0041: both UIs' policy now allows first-party, pinned WebAssembly that a test lists by digest. This alternative stays rejected, for its other reasons: WordCraft's build is a third party's, and its internals change every release.) - A CRDT, chosen by lattice. lattice already has what a CRDT exists to do without: a server that orders every write and numbers each workspace's log (ADR-0005). Its collaboration is turn-based, through revisions and proposals (ADR-0009). A CRDT guarantees convergence, not a valid file. No craft app uses one, so
.docxwould have to be converted into a generic CRDT, which is lossy. It becomes worth it only for offline editing over hours, or several people typing at once. - A session container started on demand (D7). It needs the Docker API, or an orchestrator holding it, and that is root on the host, reachable from what the application can reach.
- LibreOffice headless. Mature, but its edits are UNO calls rather than commands, and it is a large process to sandbox per call. It stays the fallback, and the
Appport admits it. - Python format libraries (
python-docxand the like): no isolation from hostile files, and an edit model per format in application code.
Unresolved questions¶
- Large documents: where the worker's round trip stops being cheap, which phase 2 measures.
- Quotas on blob bytes per workspace or tenant, and where they are enforced.
Tracking¶
- artifactr: the lease on
WorkspaceandStorage,ArtifactBusy, its ADR, and the ADR on native files as JSON Patch - artifactr-files: the package, its
pyproject.toml, amoon.ymlthat depends on artifactr and lattice-core, and its entry inrelease-please-config.jsonand the manifest - artifactr-files: blobs, check-outs, migrations and row-level security
- artifactr-files: the WordCraft adapter, the corpus and the nightly fidelity report
- artifactr-files and stackr: the worker, its image, the recipe and the release pin check
- artifactr-files:
NativeFileTools, asnative_file_tools - artifactr-files:
files_router, check-out and check-in over HTTP, and the hosted editor's policy - portalr: the renderer, previews, diffs, check-out and check-in screens
- The streamed session spike and its recommendation
- Docs: the package's section and guide
Evidence¶
Gathered on 2026-10-16. Everything downloaded was treated as untrusted data, in a scratch directory outside the repository; nothing was built from source. WordCraft was read at main eae24217 and v0.7.0.
artifactr¶
- The test guards a stale replace. At artifactr
a267cf16,apply_patchwith ateston/content/sha256and areplaceof/contentapplies to data whose digest matches. On moved data it raisesPatchFailed: the JSON patch does not apply: ccc (<class 'str'>) is not equal to tested value aaa (<class 'str'>). Verified. - Accepting re-applies.
core.rules._apply_changeapplies an accepted edit's stored patch to the current version, and only proposing checksbase_version. Verified in code. render_for_agent(self) -> stris synchronous, andclaim_threadisWorkspace's only lease, askingartifactr:claim_threadfirst. Verified in code.
WordCraft¶
| Claim | Finding | |
|---|---|---|
| Licence | LICENSE-MIT and LICENSE-APACHE; MIT OR Apache-2.0 in Cargo.toml; fonts under OFL-1.1 |
Verified |
| Brand licence | docs/brand/LICENSE-brand.txt: the ArtCraft name, wordmark and mark are trademarks, not open source. They may be used only as part of copies of the repository, and within WordCraft and its official builds, to identify it, and exactly as supplied. They must not suggest that the ArtCraft team endorses any other product. Neither they nor the ArtCraft name may appear in the name of anything else. A modified version must remove them. So serving the unmodified official build keeps its marks lawfully; a fork wouldn't; and the package's name must not use the brand. "WordCraft" itself is not among the marks the file lists |
Verified (text read; not legal advice) |
| Releases | v0.1.0 to v0.7.0, 2026-10-07 to 2026-10-14; SHA256SUMS.txt, but no signatures, SBOM or attestations, and no container image found. The CLI comes only inside the full Linux tarball, and needs only the C library |
Verified; the image unverified |
| MCP | wordcraft-cli mcp: stdio only, 16 tools, read and write roots; never sends notifications |
Verified, live |
| Control channel | In the desktop app: 127.0.0.1, a 256-bit key, newline-delimited JSON, request and response only | Verified in code |
| Commands | 617 in the v0.7.0 binary (389 change the document); the README says 422; parameters are free-text signatures. They act on positions and the selection; paragraphs and cells have no ids; comments have durable ids | Verified |
| Comments and tracked changes | inspect_document gives counts only; review.comments and review.changes give author, date, anchor, text and id |
Verified, live |
| Saves and network | Saves are atomic (a temporary file and a rename); outside changes go unnoticed. No HTTP client in Cargo.lock, no telemetry |
Verified in code |
| Browser build | wordcraft-web-0.7.0.zip, checksum matched: 39.8 MB of WebAssembly (14.9 MB gzipped; its HOSTING.md says 13 and 5). No JavaScript API, no postMessage. No COOP or COEP, no workers, no remote URLs. It shares the CLI's engine crates |
Verified from the files; not served or run |
| CRDT | No CRDT or OT library in any of the thirteen apps' lock files | Verified |
| The other apps | All thirteen have public repositories under github.com/storytold, with LICENSE-MIT and LICENSE-APACHE. Their control channels differ, and DeckCraft's and GridCraft's have no authentication. Their formats are their READMEs' claims |
Licences verified; nothing else run |
Running it. On Docker 29.4.0 on aarch64, in debian:bookworm-slim at sha256:7c7b2c96…c587, with the release's checksummed tarball: docker run --rm --network none --read-only --memory 1g --cpus 1 --pids-limit 256 --tmpfs /tmp, read-only mounts for the binary and corpus, running wordcraft-cli mcp --automation-read-root /in --automation-write-root /out, driven over stdio by a script of our own.
- The corpus: six generated
.docxfiles covering headings, run formatting, lists, a table, a footnote, an image, a comment and tracked changes. No real-world documents were tested. - Start-up: about 4 ms inside the container, against 0.4 to 8 s to start one. Calls took 1 to 41 ms, and all succeeded. The host's Docker defaults to amd64, so the aarch64 binary needed
--platform linux/arm64; x86_64 wasn't run. - Round trip: text, styles, run properties, lists, the table, the footnote, the comment, the tracked changes and the image's bytes were kept exactly. Saving added
app.xml,settings.xml, a theme andcommentsExtended.xml, and renumbered the tracked changes' ids. Across two processes, renders were identical, and so were the.docxfiles exceptdocProps/core.xml, where every save setsdcterms:modifiedto the time, incrementscp:revisionand writes--author.