Skip to content

Artifacts

An artifact is a thing the assistant made that lives beside the chat instead of inside it: a study guide, a handout, a practice quiz, a deck of flashcards, a slide deck to present, all of them things the person will keep and come back to. It opens in the conversation panel the moment the model starts writing it and fills in as the model writes, keeps every version, can be edited by hand, renamed, restored, printed, exported and reopened from a library, and never gets pasted back into the reply. This doc covers the chassis and its four kinds, document, quiz, flashcards and slides; the follow-ups at the end list what later items add. The agent loop and its tool registry are in agent-retrieval.md; the typed API framework the routes use is in spa-and-api-framework.md.

The models

apps/main_app/models/artifacts.py holds four rows, plus the export row under "Export" below.

Artifact is the identity: a UUID id, the owner (SET_NULL, for the same reason Conversation.user is - chat data outlives the person), the conversation and origin_message it came from, the kind, the live title, and archived_at. ArtifactVersion is one immutable snapshot: number (1-based, unique per artifact), source (assistant, restore or person), restored_from_number, the title at the time, the content JSON, the sources the version drew on, a change_summary, the assistant message that made it, and created_by. ArtifactDraft is the owner's edit in progress, one per artifact (OneToOneField): the title and content being typed, the base_version the edit started from, and a revision counting the saves since; its modified timestamp is what the idle rule reads. ArtifactRender is the drawn pages of one version, one per version (OneToOneField): the status (queued, running, ready, failed), the page_count once there are pages, an error_message when there are not, created_at, the queued_at the deadline counts from, and finished_at.

Versions are append-only and a restore is a new version copied from an older one, so the current version is the highest number and there is no current_version column. A check constraint pins that a restore names its origin and nothing else does; another pins kind and source to their enums.

Reads go through Artifact.objects.owned_by(principal), which takes an EndUser and nothing else, so an anonymous caller is a type error rather than an empty result. ArtifactService (services/artifacts/service.py) is the only writer: create, add_version, restore, rename, archive, link_to_message, and the draft operations under "Drafts" below. Every write to an existing artifact takes a row lock on it (select_for_update through owned()), and every write audits itself (below).

The kind contract

services/artifacts/kinds/ has one module per kind and one dictionary entry each. A kind supplies the Pydantic model that shapes ArtifactVersion.content, a to_html renderer, and a prompt projection; CONTENT_MODELS maps ArtifactKind to the model. Every model carries kind as a required literal, and ArtifactContent is the discriminated union over the five, so the tool schema, the OpenAPI document and the generated TypeScript all narrow on kind. The stored JSON keeps no kind: dump_artifact_content is the one writer and leaves it out, and parse_artifact_content(kind, raw) injects the row's kind column, so the column stays the one stored home of the fact, a payload whose own kind differs from the row's is refused by name, and no row is rewritten when a kind is added. ArtifactContentInvalid names the fields that failed and never the value: a body is too large and too personal to echo into a log. Every model mixes in HiddenTextRepr so its repr never carries text, which is one of the three measures that keep an artifact out of a Sentry event (test_artifact_sentry.py; the others are the repr=False fields on the loop's tool-call dataclasses and the DraftSaveRequest repr).

document (kinds/document.py) is DocumentBody, one body_html string capped at DOCUMENT_BODY_MAX_CHARS. Its validator strips [S#] citation markers and runs the body through services/html_sanitize.py, the Python home of the assistant-HTML allowlist that the SPA's sanitize.ts mirrors; test_artifact_sanitizer.py pins the two allowlists equal. Every HTML field of the other two kinds goes through the same clean_html_field (kinds/_fields.py).

quiz (kinds/quiz.py) is QuizBody: optional instructions HTML and one to fifty questions, each a union on type of multiple_choice (two to eight options, exactly one correct), multiple_select (two to eight options, at least one correct), true_false (answer) and short_answer (answer_text, self-graded when taken). Every question has a stem, points from 1 to 100, and optional explanation, hint and source_ref; to_html(content, answer_key=) is the print form with or without the key marked.

flashcards (kinds/flashcards.py) is FlashcardsBody: one to two hundred cards of front and back HTML, up to ten tags of forty characters, an optional hint and source_ref; fronts must be distinct by their case-folded plain text, so a deck never asks the same thing twice. to_tab_separated(content, title, target=) writes the Anki file (header lines #separator:tab, #html:true, #deck:<title>, #tags column:3, then front, back and space-joined tags per row) or the Quizlet file (plain text, one tab per row, no tags).

slides (kinds/slides.py) is SlidesBody: a theme chosen from six designed palettes (midnight, parchment, laboratory, slate, meadow, ink), and two to forty slides, each a union on layout. A title slide has an eyebrow, title and subtitle and is the first slide and only the first; a section slide is a divider of title and subtitle; a bullets slide has a title, subtitle and one to six bullets; a two_column slide has a left and a right, each a heading and its bullets; a closing slide has a title, subtitle and up to three takeaways. A title or bullets slide may carry one aside, a union on kind of a code card, a callout or a quote. Bullets take the same inline allowlist the other kinds take; every other field is plain text, and every slide carries plain-text speaker notes. The model picks the theme and the layouts; it never picks a colour or a font.

to_html is the handout the Word, HTML and Google Doc exports carry, one section per slide with its notes under it. The PowerPoint file is a different thing entirely: exports/pptx.py draws every slide itself on a blank layout, and the deck's PDF is Impress's conversion of that file rather than of the handout, so the PDF is the slides and carries no notes. The panel reads the deck as the drawn pages of that same file, which is "The drawn pages" below.

A question's or a card's source_ref is one source key, file:<id> or web:<url>, the form citations.source_key produces; the model writes a turn's [S#] ref there and the tool rewrites it to the key before the save, and a [S#] ref in stored content is a validation error. artifact_prompt_text is what the model reads back as the active artifact: a document's HTML, or every other kind's compact JSON with kind included, because update_artifact takes the whole content back and a projection would hide the answers, hints, points, types, refs, speaker notes the model would otherwise have to reinvent.

The caps per kind are module constants (QUIZ_MAX_QUESTIONS, STEM_MAX_CHARS, FLASHCARDS_MAX_CARDS, FLASHCARD_SIDE_MAX_CHARS, SLIDES_MAX and the rest), and the SPA's form editors carry the same numbers so a person is told before the server would refuse. Adding a kind is a new module with its kind literal, a member on ArtifactKind and the union, one entry in CONTENT_MODELS, a case in artifact_to_html and artifact_prompt_text, a delta shape in events.py and its counting arm in artifact_stream.py, and a migration that widens the kind check constraint, since the enum is pinned in the database as well as in the code. It is also a line in the prompt's kind rule, the same line at the end of both tool descriptions, the list of kinds on load_artifact_tools, and the catalog blurb in artifact_context.py, or the model has the kind and no idea when to reach for it. On the SPA it is a case in ArtifactBody and ArtifactEditor, an entry in KIND_LABELS, KIND_TRAITS, KIND_ICONS and EMPTY_BY_KIND, and a fixture in test/artifactFixtures.ts; assertNever and the Record<ArtifactKind, ...> types turn every one of those into a compile error until it is written.

The tools and the loop

services/agent/tools/artifacts.py holds create_artifact(title, content, source_refs?, change_summary?) and update_artifact(artifact_id, content, change_summary, title?, source_refs?), and neither of them is in the registry. A change_summary describes what the artifact covers or what changed and never counts its questions, cards or slides, because the counter on the artifact is the one source of that number, and both field descriptions tell the model so. Each one carries the whole four-kind content union in its argument schema, which is what the token table below counts, and both would ride on every model call of every turn if they were registered. What the registry holds behind the cc_artifacts switch is load_artifact_tools (services/agent/tools/load_artifact_tools_tool.py), a bootstrap tool with no arguments, the same progressive disclosure the connector actions use (connectors-philosophy.md section 9).

load_artifact_tools sets AgentState.artifact_tools_loaded and returns one line: the two tools are available in the next response, and their descriptions say how to write one. How to write an artifact is not in that result, because the two tool descriptions and the field descriptions on their schemas already hold every one of those rules, and the model reads them the moment it has the tools. rebuild_tools_with_loaded_extensions (services/agent/tool_wiring.py) runs at the start of every iteration and, when the flag is set, appends both tools built with the principal, state.conversation_id and the run state, so the model can call either of them from its next response to the end of the turn. That append sits above the early return the rebuild takes when no connector is loaded, which is what most turns do. The rebuild is also the only place a bound artifact tool comes from: conversation_id and run_state are required keyword arguments of _ArtifactToolBase, so a tool that would save into no conversation and resolve no source_refs is a type error rather than a quiet half-write.

A call to create_artifact or update_artifact before the loader has run is a call to a tool the iteration does not carry, and three things answer it. not_found_error (services/agent/tool_execution.py) tells the model that create_artifact is not loaded, that load_artifact_tools comes first and that it can call create_artifact in its next response, so it has a route back. It says that only when the loader is one of the iteration's own tools: a turn without the cc_artifacts flag carries neither the pair nor the loader, so an artifact name there keeps the plain Tool '<name>' not found every other missing name gets, rather than being sent after a tool that is not there either. absent_tool_display_args beside it is what the span and the ToolCallEvent show for that call, {"argument_names": [...]} and none of the values, because a tool that is not there vets nothing and an artifact body must never reach a span or Sentry's frame locals. The loop's panel branch reads ctx.state.artifact_tools_loaded as well, so such a call opens no panel and the person sees the narration alone rather than a card that fails.

create_default_registry(principal, artifacts=True) adds load_artifact_tools and nothing else; the flag is a capability switch the chat service derives from cc_artifacts, not part of the identity. The guest registry never has it. The registry's exact-set test in tests/test_agent_core.py pins both the plain set and that one-tool difference.

Both artifact tools are identity-bound (they hold the principal) and run the ArtifactService write under sync_to_async. The kind is the content.kind literal, not a separate argument, and each description ends with the one-line kind rule (questions to practise are a quiz, things to memorise are flashcards, a talk to give is slides, everything to keep and reread is a document); update_artifact refuses a content whose kind differs from the row's, naming both. source_refs carries the turn's [S#] refs; the tool resolves them, together with every question's and card's source_ref, against the run's citation registry into Source rows stored on the version, rewrites the per-item refs to their source keys, and reports any ref it could not resolve in the result text. A successful call returns ArtifactSaved under ToolResult.metadata["artifact"], which is how the loop learns an artifact was made without reading the body.

Three things in the loop (services/agent/agent_core.py) are artifact-specific. On the first ToolCallDelta of an artifact tool call, and only once the turn has loaded the tools, the loop emits ArtifactStartEvent (tool call id, step, tool name) once per call, before the arguments have finished streaming, so the panel opens on a skeleton seconds before the row exists. While the arguments stream, the loop appends each fragment to ArtifactArgumentBuffers (services/agent/artifact_stream.py) and emits ArtifactDeltaEvent whenever the partial parse has something new: the title once it has closed, then a DocumentDelta of the body characters that arrived, a QuizDelta counting the questions whose stem has closed, a FlashcardsDelta counting the cards whose front has, or a SlidesDelta counting the slides whose title has. Each counted field closes before the rest of its item, so a count names the item the model is writing rather than the last one finished. A revision's deltas carry the artifact's current title until the model streams one of its own, read from the title map build_artifact_context built for the turn, because update_artifact streams title after the body and may omit it altogether. The reader parses with jiter in partial mode, so a fragment that leaves a string open still yields the text inside it; a buffer that does not parse yields nothing rather than an error, and the buffer is dropped when the call completes. When the call's arguments complete, the loop emits ArtifactSavingEvent (tool call id, step) for every call it announced, so the panel can say the writing is over while the tool validates the content and writes the version. When the tool result carries metadata["artifact"] the loop emits ArtifactEndEvent (tool call id, artifact id, kind, title, version, change summary). A failed call emits no end event; the client closes the skeleton on the failed tool_result. None of the four - start, delta, saving, end - is persisted: the saved version is the record.

The body never reaches a span, a log, or the ToolCallEvent: the tools' display_args replaces content with content_chars, the llm_output span event carries the same view of each pending call, and the executor stamps campuscore.artifact.id, .kind and .version on the tool span after the save (services/observability/attributes.py). narrate in services/agent/tool_narration.py labels the trace rows: the loader reads "Getting ready to write" and then "Ready to write", and an artifact call reads "Drafting """ or "Revising "<title>"" while it runs and "Drafted "<title>"" or "Revised "<title>"" when it saved, from the title argument and never the body.</p> <h2 id="the-turn">The turn<a class="headerlink" href="#the-turn" title="Permanent link">¶</a></h2> <p><code>ChatService._prepare_turn</code> reads <code>cc_artifacts</code> for the person, by id and roles, once per turn (<code>is_feature_enabled_for_person</code>). When it is on, every open draft on the conversation's artifacts is promoted to a version first (<code>promote_conversation_drafts</code>, the turn-start moment under "Drafts"), then the turn's agent is built with the artifact tools and the prompt variant that carries the <code>## Artifacts</code> section, and the volatile prompt block gains the <code>[ARTIFACTS IN THIS CONVERSATION]</code> section from <code>services/agent/artifact_context.py</code>. That section is one catalog line per unarchived artifact of the conversation (<code>artifact_id</code>, kind, title, current version), then the current content of the active artifact when there is one, in its <code>artifact_prompt_text</code> form. Content past <code>ACTIVE_BODY_MAX_CHARS</code> (60,000) is not sent at all: the section says the artifact is too large to include and that <code>update_artifact</code> must not be called for it, because a cut blob cannot be handed back whole.</p> <p>The active artifact is the id the request names in <code>active_artifact_id</code> when it is one of this conversation's, else the artifact versioned by the previous assistant message, so an immediate "make section two shorter" works with the panel closed. Otherwise no body is sent: a body is up to the cap of uncached input on that turn. An id that fails the ownership check is ignored, not an error, because the panel can be stale. The root span's copy of the dynamic context shows <code>[artifacts: N chars]</code> in place of the section.</p> <p>The assistant row is created at the turn's first text, note, tool call or <code>artifact_end</code>, whichever comes first, so a turn stopped right after the save still has a row to carry the card; a row with empty text and an artifact renders as the card alone, with "This answer is incomplete." under it when the turn was interrupted. Every <code>artifact_end</code> of the turn is collected, and once the assistant row is saved <code>_link_artifacts</code> stamps it on those versions (<code>ArtifactVersion.message</code>) and on a new artifact's <code>origin_message</code>. The turn's audit row lists the artifact ids under <code>details["artifact_ids"]</code>. The transcript card comes from <code>ArtifactVersion.message</code>, not from <code>Message.agent_metadata</code>, which is why artifact events are not persisted in the trace; a turn that revised a document carries the card as well as the turn that made it.</p> <p>What artifacts cost the model per call, counted with <code>o200k_base</code> over the compact JSON of <code>to_openai_tool()</code> plus the section text, the way <code>tests/test_artifact_tool_tokens.py</code> counts it, measured on 2026-09-22:</p> <table> <thead> <tr> <th>What the call carries</th> <th>Tokens</th> </tr> </thead> <tbody> <tr> <td>Always on: the <code>## Artifacts</code> section (278) and the <code>load_artifact_tools</code> schema (96)</td> <td>374</td> </tr> <tr> <td>Loaded on demand: <code>create_artifact</code> and <code>update_artifact</code> together</td> <td>10,274</td> </tr> </tbody> </table> <p>The always-on row is paid on every model call of every turn a person with the flag takes, whether or not they ever ask for an artifact. The loaded row is paid from the call after the model asks for the tools to the end of that turn, so a turn that makes an artifact spends one extra model call and opens the panel one round trip later, and a turn that makes none saves the whole 10,274. Both are our own count of our own serialisation, not the provider's: driven against the dev stack on the same day, a model call carrying the loaded pair came back about 8,400 input tokens above one without it. <code>test_artifact_tool_tokens.py</code> keeps both figures as constants beside a ceiling and a ten percent drift band, so a schema or prompt edit that moves either one turns the module red and the constant and this table are updated together. An active body of about 3,500 characters costs about 900 tokens more on the turn that carries it. A signed-in turn pays two queries for the flag, one for the catalog, and when no id is named two more to find the previous answer's artifact, then one for the active body when there is one.</p> <h2 id="the-prompt">The prompt<a class="headerlink" href="#the-prompt" title="Permanent link">¶</a></h2> <p><code>prompts/agent_system_prompt.py</code> keeps the artifacts text in <code>_ARTIFACTS_SECTION</code>, composed only into <code>AGENT_PROMPT_WITH_ARTIFACTS_TEMPLATE</code>; the plain and guest variants carry no <code>## Artifacts</code> section and name none of the three tools. The section is four rules, and it says only what the model has to decide before it holds any artifact tool: what an artifact is and that it opens in a panel beside the chat, which kind follows which use, that a thing to keep becomes an artifact while a question gets an answer in the chat, and that <code>load_artifact_tools</code> is called first with the artifact tool in the next response. The rules for writing one live on the two tool descriptions and their field descriptions instead, where the model reads them at the moment it needs them and a turn that makes no artifact never pays for them. The member scope's coursework rule still wins over rule 3, so a graded deliverable is refused rather than drafted as a document. The fallback template's rule 7 covers the case where the model has already made the document earlier in the turn: it stays in the panel and the reply says what to check. <code>test_agent_prompt.py</code> pins that the section names <code>load_artifact_tools</code>, the four kinds and the load-first instruction, that none of the three tool names appears in the plain or the guest variant, and that the writing rules left the section for the tool descriptions.</p> <h2 id="the-flag">The flag<a class="headerlink" href="#the-flag" title="Permanent link">¶</a></h2> <p><code>cc_artifacts</code> is a user-scoped flag registered in <code>apps/auth/features.py</code> under the Chat category and enabled per role in Settings (<a href="../rbac-and-feature-flags/">rbac-and-feature-flags.md</a>; the registration recipe is <a href="../adding-a-feature-flag/">adding-a-feature-flag.md</a>). It gates three things from one read: the tools and prompt section on the turn, the <code>/api/artifacts</code> routes (403 for a role without it), and the SPA surface (<code>useFeatureEnabled("cc_artifacts")</code> hides the sidebar link, the panel section and the library, and <code>/artifacts</code> redirects to chat). A guest is 401 on the API and has no artifact tools.</p> <h2 id="the-api">The API<a class="headerlink" href="#the-api" title="Permanent link">¶</a></h2> <p><code>apps/main_app/apis/artifacts_api.py</code>, all under <code>/api/artifacts</code>, every route owner-scoped through <code>ArtifactService.owned()</code> so another person's id is 404 everywhere:</p> <table> <thead> <tr> <th>Route</th> <th>Returns</th> </tr> </thead> <tbody> <tr> <td><code>GET /api/artifacts?conversation_id&kind&after&limit</code></td> <td><code>ArtifactList</code>: summaries only (no bodies), keyset-paged on <code>(modified, id)</code></td> </tr> <tr> <td><code>GET /api/artifacts/{id}</code></td> <td><code>ArtifactDetail</code>: the summary plus the current <code>content</code>, <code>sources</code>, and the version list</td> </tr> <tr> <td><code>PATCH /api/artifacts/{id}/rename</code></td> <td>the detail with the new live title; the version titles keep their history</td> </tr> <tr> <td><code>GET /api/artifacts/{id}/versions/{n}</code></td> <td><code>ArtifactVersionDetail</code>: one older body</td> </tr> <tr> <td><code>POST /api/artifacts/{id}/versions/{n}/restore</code></td> <td>the detail with the new version on top</td> </tr> <tr> <td><code>POST /api/artifacts/{id}/archive</code></td> <td>204; the artifact leaves every list and the panel, its versions stay</td> </tr> <tr> <td><code>PUT /api/artifacts/{id}/draft</code></td> <td><code>ArtifactDraftDetail</code>; <code>DraftSaveRequest(title, content, base_version, revision)</code> creates the draft at <code>revision</code> 0 or replaces it at the revision held; 409 <code>draft_promoted</code>, <code>draft_conflict</code> or <code>stale_base</code> (below)</td> </tr> <tr> <td><code>POST /api/artifacts/{id}/draft/discard</code></td> <td>204; the draft is gone and the current version stands</td> </tr> <tr> <td><code>POST /api/artifacts/{id}/draft/promote</code></td> <td>the detail with the <code>person</code> version on top; <code>DraftPromoteRequest(revision, change_summary?)</code>, the same 409s</td> </tr> <tr> <td><code>GET /api/artifacts/{id}/flashcards.txt?target=anki\|quizlet</code></td> <td>the tab-separated file as an attachment (<code><title-slug>-<target>.txt</code>); 404 for any other kind</td> </tr> <tr> <td><code>GET /api/artifacts/{id}/renders/{n}</code></td> <td><code>ArtifactRenderDetail</code>, a union on <code>status</code>: <code>queued</code> or <code>running</code>, <code>ready</code> with <code>page_count</code> and the page routes in order, or <code>failed</code> with the sentence to show; 404 when the version or the row is not there</td> </tr> <tr> <td><code>POST /api/artifacts/{id}/renders/{n}/retry</code></td> <td>the same detail, queued again with the deadline counting from now; 409 <code>render_not_failed</code> when the row is neither failed nor past its deadline</td> </tr> <tr> <td><code>GET /api/artifacts/{id}/renders/{n}/pages/{i}</code></td> <td>one drawn page as <code>image/png</code>, inline, cached <code>private, max-age=31536000, immutable</code>; 404 when the render is not ready, the index is past the count, or the object is gone</td> </tr> </tbody> </table> <p><code>ArtifactDetail.content</code> and <code>ArtifactVersionDetail.content</code> are the <code>ArtifactContent</code> union, so the generated client narrows on <code>content.kind</code>; the detail also carries <code>draft</code> (an <code>ArtifactDraftDetail</code> of <code>title</code>, <code>content</code>, <code>base_version</code>, <code>revision</code>, <code>updated_at</code>) when the owner has one open, and reading the detail runs the idle rule first. The draft save takes <code>content</code> as a raw object parsed by the row's kind, and a payload whose own <code>kind</code> differs is 422.</p> <p><code>AssistantMessage.artifacts</code> on the transcript carries a <code>MessageArtifact</code> per unarchived artifact the row wrote a version of (<code>ArtifactVersion.message</code>), gathered in two queries for the whole transcript, so a reload shows the card on the creating turn and on every revise turn without a second request. A <code>MessageArtifact</code> is the <code>ArtifactSummary</code> plus <code>version</code>, the number of the version that message wrote; a turn that wrote two versions of one artifact carries one card, naming the higher number.</p> <h2 id="drafts">Drafts<a class="headerlink" href="#drafts" title="Permanent link">¶</a></h2> <p>A person's edit is a draft until it becomes a version; a draft is never what the assistant reads, and a version is never edited in place. <code>save_draft</code> creates the draft at <code>revision</code> 0, with the current version as <code>base_version</code>. With a positive revision it replaces the draft at the revision the caller holds and bumps it.</p> <p>A save is refused three ways, each a 409. <code>DraftConflict</code> (<code>draft_conflict</code>) means the stored draft is at another revision, which is how a second window finds out about the first; it carries the stored draft. <code>DraftStale</code> (<code>stale_base</code>) means a creation named a base that is no longer the current version. <code>DraftPromoted</code> (<code>draft_promoted</code>) means a positive revision found no draft at all, because one of the promotion moments below has already turned it into a version; it carries the current version number.</p> <p>A draft becomes a <code>person</code> version, with the change summary <code>Edited by hand</code>, at four moments. The person clicks Save version (<code>promote_draft</code>). An assistant turn starts in the conversation (<code>promote_conversation_drafts</code>, run before the context is built, so the model always reads the person's words); a draft its kind now refuses is left in place and logged, so one broken draft cannot end every turn in that conversation. An export goes out (<code>promote_open_draft</code>). A draft has sat untouched for <code>DRAFT_IDLE</code>, ten minutes, and anything reads the artifact or saves to it (<code>promote_if_idle</code>, run in its own transaction ahead of a save so the promotion outlives a refused save). <code>add_version</code> and <code>restore</code> promote an open draft before they write, so the person's version always sits under the assistant's or the restored one. Discarding deletes the draft and nothing else.</p> <h2 id="streaming-into-the-panel">Streaming into the panel<a class="headerlink" href="#streaming-into-the-panel" title="Permanent link">¶</a></h2> <p>The turn's frame reducer (<code>lib/streaming/liveTurns.ts</code>) handles <code>artifact_delta</code> by writing the title and the count to the store slot <code>artifactStreams[toolCallId]</code> and, for a document, accumulating the text in a ref that <code>createAnimationFrameFlush</code> writes to the store once per animation frame, so a body arriving one token at a time costs one render per frame, not per token. It handles <code>artifact_saving</code> by moving that slot to <code>stage: "saving"</code>, making the slot first when the call's kind never parsed, so a call nobody could name still says what is happening to it. The skeleton (<code>ArtifactView</code>) reads the slot and shows the pulsing title and, for a document, the body as it grows through <code>StreamedDocument</code>; every other kind shows the line from <code>progressLine</code> ("Writing question 5...", "Writing card 12...", "Writing slide 3..."). A count names the item being written, so a five-question quiz ends on question five and no line ever names an item past the last; from the saving frame until the save lands every kind reads "Saving...". The <code>ArtifactCard</code> under the live text and the panel heading read the same slot, so the kind's icon, the title and the saving line appear on all three at once.</p> <p><code>StreamedDocument</code> does not sanitise the whole body per frame. <code>lib/artifacts/streamedBlocks.ts</code> finds the top-level blocks that have closed since the last settled boundary, and the view sanitises each settled block once and appends it to a settled container, re-sanitising only the open tail every frame; malformed markup only stops blocks from settling, so the view degrades to a longer tail, never to dropped text. Measured in the live page on a 57,000-character body replayed at ten characters per frame, a frame costs 0.7 ms at the median and 2.9 ms at the 95th percentile, against 16 ms for one sanitiser pass over the whole body. The slot is cleared by the chat route after it swaps the panel to the saved artifact at <code>artifact_end</code>, and by the turn store on every other end of a turn.</p> <h2 id="the-spa">The SPA<a class="headerlink" href="#the-spa" title="Permanent link">¶</a></h2> <p><code>web/src/lib/api/artifacts.ts</code> holds the query keys and hooks; every mutation invalidates the artifact keys and the conversation keys, because the transcript card carries the title and version.</p> <p>The turn store (<code>lib/streaming/liveTurns.ts</code>, read through <code>useChatStream</code>) keeps <code>artifacts: ArtifactTurnItem[]</code> per turn: <code>drafting</code> on <code>artifact_start</code> (filled in by <code>artifact_delta</code>, above), <code>saved</code> on <code>artifact_end</code>, <code>failed</code> when the call's <code>tool_result</code> reports failure. A drafting item also closes as <code>failed</code> when the turn ends without an <code>artifact_end</code> for it: a stop, the response timeout, a stream that ends without <code>done</code>, or a <code>done</code> that reports an error. A stopped turn that produced no text keeps its transcript block while it holds an artifact item, so the failed card stays on screen. <code>ChatRoute</code> renders an <code>ArtifactCard</code> per item under the live text and, on desktop only and never over a collapse the person chose, opens the panel on the skeleton at <code>artifact_start</code> and swaps it to the saved artifact at <code>artifact_end</code>. Both send paths pass <code>active_artifact_id</code> from the open panel detail, whether it is being read, edited or lies under a source opened over it (<code>artifactBeingRead</code>), and none of those is replaced by a skeleton. <code>MessageBubble</code> renders the same card from <code>message.artifacts</code> for a saved row. The card is a rounded row: the kind's icon tile, the title, a <code><Kind> · v<n></code> line, and an Open affordance; the change summary is not on it and lives in the versions menu. Until a title has streamed the card and the skeleton heading both say what <code>placeholderTitle</code> returns for the call's tool, "Drafting" for a creation and "Revising" for a revision, which is why the skeleton detail carries the tool name as well as the tool call id. The <code>v<n></code> is the version that turn wrote, live and after a reload, so a revision that writes v2 leaves the creating turn's card reading v1; clicking either card opens the artifact as it stands now.</p> <p>The panel state (<code>uiStore.ts</code>) is <code>PanelDetail = ChatSourceDetail | ArtifactPanelDetail</code>; the artifact variant is <code>skeleton</code> (the tool call id and tool name of the call being written), <code>failed</code>, <code>open</code> (an artifact id and an optional older version number), or <code>edit</code> (an artifact id). <code>ConversationPanel</code> lists the conversation's artifacts above Context and Sources, asks for them only when the flag is on, and renders the open detail through <code>DetailSlots</code>: <code>SourceDetail</code> for a source, and <code>ArtifactView</code> for an artifact on show or under a source.</p> <p>A source opened while an artifact is open to read opens over it. The store's <code>openDetail</code> records the open artifact as the source's <code>over</code>, and keeps it when a second source opens over the first; a source opened over the list, a skeleton or an editor has <code>over: null</code>. Openers pass a plain <code>SourcePanelDetail</code>, so only the store sets <code>over</code>. <code>ConversationPanel</code> keeps the artifact in one slot, hidden under the source with the <code>hidden</code> attribute, so the same <code>ArtifactView</code> stays mounted. Back returns to the artifact as the reader left it: a quiz on the same question with its checks and counts, a deck on the same card and flip, and the same quiz display. A document keeps its scroll position too, which <code>sourceOverArtifact.browser.test.tsx</code> checks in Chromium and WebKit. Focus returns to the control that opened the source, and a second Back reaches whatever opened the artifact. While the source shows, the panel keeps the artifact's width, the header and focus follow the source, and neither Tab nor a screen reader reaches the hidden artifact. Opening another artifact, or the agent opening a skeleton, replaces both. A revision of the artifact under the source opens no skeleton: the saved version replaces the hidden artifact in place, and the attempt starts over because the body is keyed by version. Collapsing the panel or switching chats unmounts the artifact and loses the attempt, though the source and its <code>over</code> survive, so Back still returns to the artifact. <code>PanelShell</code> is a floating rounded card where it docks and a full-screen sheet where it does not; its one header row takes an optional <code>heading</code> in place of the plain title and an optional <code>actions</code> node before the collapse button. <code>heading</code> is a function of the heading ref, because a detail opened from a chip, a card or a panel row takes focus on its heading and hands it back on close (see <a href="../spa-design-system/#the-side-panel">the side panel's focus</a>). It docks only in a window that holds it: the viewport at <code>md</code> or wider, and room for the panel's own <code>PANEL_MIN_WIDTH</code> beside <code>CHAT_COLUMN_MIN_WIDTH</code> of conversation once the rail and the card's inset have theirs (<code>panelHasRoomToDock</code>, read everywhere through <code>usePanelDocks</code>). Otherwise it opens as the sheet, as it does on a phone - and a window narrowing under a docked panel takes the card down to its floor before the column goes under its own. It takes its <code>width</code> from its caller and reports a drag back through <code>onWidthChange</code>, because how long a width lasts is the caller's policy: <code>components/panel/panelWidth.ts</code> opens an artifact, or a source over one, at half the room beside the navigation rail and a cited source or the list at <code>PANEL_MIN_WIDTH</code>, and <code>ConversationPanel</code> keeps a drag under the conversation it was made in for the life of the tab. <code>ArtifactToolbar.tsx</code> fills those two slots for an artifact. <code>ArtifactHeading</code> is the kind's icon tile, the inline-rename title, and the <code><Kind> · v<n></code> line, which reads <code>Viewing v<n> of <m></code> on an older version. It places the heading ref on every heading it renders - the skeleton, the failed and the loading heading, and <code>TitleEditor</code>'s - so focus follows the detail when the agent saves it. <code>TitleEditor</code>'s button shows the title as its text and is named "<title>, rename", so its name starts with the words on screen and says what pressing it does. It shows Rename as a <code>data-tip</code> tooltip hung from the title's start, because a <code>title</code> beside the <code>aria-label</code> would become the button's description and a screen reader would say "rename" twice. The heading around the button takes its name from the button, so it is announced as "<title>, rename" too. Every dot of that line is one <code>KindLineSeparator</code> (<code>components/panel/separators.tsx</code>), whose own margins are the whole of its spacing, so a quiz's display toggles sit the same distance from their dot as the version number does from its own. The full page's meta line keeps <code>MetaSeparator</code> from the same module, whose spacing is written into the text so the line can wrap in front of a dot and never after one. <code>ArtifactActions</code> is a row of icon buttons: Restore while an older version is shown, a history button holding the versions <code>Menu</code>, Edit, Print, a flashcards deck's Download, and Open full page. A row of that menu reads <code>v<n> · <summary or source> · <time></code>, the current version is marked, and a <code>person</code> version reads <code>Edited by hand</code>.</p> <p>The controls themselves live once, in <code>components/panel/ArtifactControls.tsx</code>: <code>VersionsControl</code> (the dropdown the panel uses), <code>HistoryControl</code> (the button the page uses to open the drawer), <code>RestoreControl</code>, <code>EditControl</code>, <code>PrintControl</code>, <code>DownloadControl</code>, <code>DraftActions</code> (Save version and Discard) and <code>TitleEditor</code>. They all wear the one face <code>face()</code> draws (<code>components/panel/controlFace.tsx</code>): a glyph in a 32px hover-wash button carrying its name as both <code>aria-label</code> and <code>data-tip</code>, the tooltip the stylesheet draws on hover and keyboard focus, in the panel's header row and in the page's control row alike, so the two surfaces cannot offer the same control in two shapes. That face, <code>ARTIFACT_CONTROL</code>, carries a tap area on a touch screen, and both rows space their controls with <code>tap-gap-8</code> (see <a href="../spa-design-system/#tap-area">the design system's tap area</a>); in the panel, a quiz's kind line moves 20px below the title on a touch screen, because its two toggles and the title are all 24px controls. <code>RestoreControl</code> and <code>DraftActions</code> are the two worded controls, by design: Restore carries a version number in its words, and Save version and Discard are edit mode's primary actions. What is offered at all comes from <code>lib/artifacts/kindTraits.ts</code>, a <code>Record<ArtifactKind, KindTraits></code> of four booleans: whether the kind exports, whether it has the Anki and Quizlet downloads, whether it prints, and whether it is a quiz, which is the one trait that carries the answer key, the doubled file formats and the display control together. The controls, the export menu, the history drawer and the library all read the traits rather than the kind, so a new kind is a row in that table instead of a condition in each of them. Restore is its own control rather than part of a history control, because the panel reaches its versions through the dropdown and the page through the drawer while restoring is the same act on both; each surface places it beside whichever history control it offers and tells it through <code>focusAfter</code> where the keyboard goes once Restore is gone with the version it restored. Where a control leads is the caller's business: <code>VersionsControl</code> reports a choice through <code>onShow</code> (the panel writes it into the open detail, the page into the URL) and <code>DraftActions</code> reports leaving through <code>onLeave</code>. <code>versionLabel</code> and <code>versionLine</code> (<code>lib/artifacts/versionText.ts</code>) are the one home of how a version reads in a list and of the "Viewing v1 of 3" line, so the dropdown, the drawer, the page header and the panel header cannot describe the same version in different words. <code>lib/artifacts/useShownVersion.ts</code> is the matching reading of which body is on show: the current one the detail already carries, or an older one fetched on its own, with a number equal to the current version reading as current so nothing is fetched twice. <code>ArtifactView</code> is the body: <code>ArtifactBody</code> in the <code>.artifact-reading</code> style (the product's own face at the reading size, <code>--text-reading</code>, with a reading measure and a tightened heading scale, shared with the full page; see <a href="../spa-design-system/#type-scale">the design system's type scale</a>) switches on <code>content.kind</code> to <code>DocumentView</code>, <code>QuizView</code>, <code>FlashcardsView</code>, or <code>SlidesView</code>, and the sources are rows that open in the panel.</p> <h3 id="editing">Editing<a class="headerlink" href="#editing" title="Permanent link">¶</a></h3> <p>Edit switches the detail to <code>edit</code>, which mounts the lazy <code>ArtifactEditor</code> chunk (TipTap, the diff library and the form editors load only then; the chunk is about 470 kB, 147 kB gzipped). <code>useArtifactDraft</code> (<code>lib/artifacts/useArtifactDraft.ts</code>) seeds from the open draft or the current version and owns the draft's life. <code>update</code> schedules a save two seconds after the last change, <code>promote</code> is Save version, and <code>discard</code> is Discard. <code>status</code> is <code>saved</code>, <code>saving</code>, <code>unsaved</code> with the error, <code>conflict</code> with the other window's draft, or <code>behind</code> with the version number that landed; a banner raised while a save was in flight stays up when that save lands. <code>holdInvalid(message)</code> keeps a form that fails its own validation from being saved or promoted, and the message shows in the heading. A save the server refuses with a 4xx other than a 409 becomes the same hold, with the server's reason, because sending that content again would be refused again. The heading in edit mode reads the kind and the state ("Quiz · Saving...", "Flashcards · Write the front."), and the actions are Save version and Discard.</p> <p>The two banners come from the 409s. <code>behind</code> (<code>draft_promoted</code> or <code>stale_base</code>) says "Version n was made while you were editing." and offers three choices: Show changes (<code>VersionDiff</code>, a word diff between the person's promoted version and the current one), Keep editing (re-creates the draft on the new version with the local text) and Use version n (loads the new version into the editor). When the current version is the one the draft started from, nothing new was written and the draft was discarded in another window, so the banner says that and drops Show changes. <code>conflict</code> (<code>draft_conflict</code>) offers Reload draft.</p> <p><code>DocumentEditor</code> is TipTap over the sanitiser's allowlist with a formatting toolbar; a tag the editor has no node for keeps its words and drops its wrapper. <code>QuizEditor</code> and <code>FlashcardsEditor</code> are forms over <code>quizForm.ts</code> and <code>flashcardsForm.ts</code>, and both ride the one editor chunk. They share their field and label faces, <code>STUDY_EDITOR_FIELD</code> and <code>STUDY_EDITOR_LABEL</code> in <code>editors/fieldClasses.ts</code>, and one field wrapper, <code>StudyField</code> in <code>editors/StudyField.tsx</code>. Each field sits inside the label that shows its words, which lends the field its tap area on a touch screen, and carries an <code>aria-label</code> that names its question or card, such as "Front of card 2". Every field is plain text, a question type is a <code>Select</code>, and questions and cards move and delete with labelled icon buttons. <code>validate</code> carries the server's caps and rules as sentences ("Mark one option as correct.", "Another card already has this front."), so a person is told inline, on the field, before anything is sent. An HTML field the person did not touch keeps its markup (<code>keepOrRewrite</code> in <code>lib/artifacts/textHtml.ts</code>); one they edited is rewritten from the plain text, so a hand edit never silently flattens the rest of the artifact. Each field's flattened text is captured once, when the form loads, and compared as a string afterwards: flattening is a DOMParser parse, and the form checks and rewrites every field of every question on each keystroke.</p> <h3 id="taking-a-quiz-and-studying-flashcards">Taking a quiz and studying flashcards<a class="headerlink" href="#taking-a-quiz-and-studying-flashcards" title="Permanent link">¶</a></h3> <p>Both views wear the same chrome, which lives in <code>kinds/studyChrome.tsx</code>: a progress header of one segment per question or card with the position and a running right and wrong count, the score ring, the Hint disclosure, the reveal, and the pill the actions are drawn as. The look and all of the motion are the <code>.study-*</code> rules in <code>styles/index.css</code>, so the reduced-motion guard in that file's base layer switches every animation off without either view knowing.</p> <p><code>QuizView</code> opens in take mode: one question at a time, Check grades it against the key (a short answer is self-graded with I was right), Next moves on, Back returns to a question already checked with its answer still on it, and See results scores the attempt in points on the ring with Try again to restart; a hint folds open before checking. Check marks each option row in place rather than opening a box below: the row the student chose reads Your answer, a right answer reads Correct answer or, when they did not pick it, Missed, the rest fade back, and the question's explanation opens once inside the right answer's row. The row's own <code>role="status"</code> carries the verdict as a sentence for a screen reader, since on screen the rows carry it. Show as text switches to the print form, and Answer key marks the key on it; both toggles live in <code>useUiStore.quizDisplays</code>, keyed by panel scope.</p> <p><code>FlashcardsView</code> opens in study mode (<code>kinds/studyLoop.ts</code>): the card is one button that Space or a click flips, with both faces mounted so it can really turn and the face pointing away marked <code>aria-hidden</code>. Correct and Again sort it and move on with focus returned to the card, Undo takes the last sort back and empties the pile it went into, and the finish screen offers Start over, Shuffle and Only the ones I missed over the ring. A sorted card is held on screen for 200ms while it slides away, which is the one timer in either view and is skipped when the student has asked for less movement. Neither mode persists anything: an attempt lives in component state and is gone on close.</p> <h3 id="slides">Slides<a class="headerlink" href="#slides" title="Permanent link">¶</a></h3> <p><code>SlidesView</code> shows the pages the server drew rather than a slide the browser lays out, so what a person reads is the PowerPoint file that downloads, page for page. It reads <code>useArtifactRender(versionRef)</code>, which polls every two seconds while the row is queued or running and stops the moment it is ready or failed. A ready render is an ordered list of framed 16:9 pages at the panel's width, each with its "Page N / M" badge at the bottom right and, under a page whose slide has notes, a "Notes" disclosure; the images past the second load lazily, and every one carries its natural 1920 by 1080 size so the frame holds its shape before the bytes arrive. While the render runs the same stack is grey placeholders under "Rendering the deck...", counted as the deck's slides plus one Sources page when the version cites anything, so the pages arrive without the layout moving. A failed render shows the row's own sentence, a Retry that queues it again, and <code>SlidesOutline</code>, the deck read as text; a render that cannot be read at all shows the outline too, so a deck is never a blank panel. The version's sources are in the History drawer whichever of those a person is looking at; the outline is the deck read as text and nothing else.</p> <p>Which body a kind gets is <code>KindTraits.body</code> (<code>lib/artifacts/kindTraits.ts</code>): <code>reading</code> for prose, which keeps the reading measure, and <code>pages</code> for a deck, which takes a wider one, because a page stands for a whole slide. <code>KindTraits.edits</code> is why a deck has no Edit button in the panel or on the page: a deck is asked for, not typed. The backend door stays open - a valid deck draft is still a valid version - and nothing in the SPA reaches it.</p> <h3 id="print-and-export">Print and export<a class="headerlink" href="#print-and-export" title="Permanent link">¶</a></h3> <p>Print never navigates: <code>usePrintArtifact</code> (<code>components/panel/PrintProjection.tsx</code>) mounts the artifact a second time in a portal on <code>document.body</code>, puts <code>artifact-printing</code> on <code><html></code>, waits for the body to say it is ready and then for two animation frames so the portal has painted, and calls <code>window.print()</code>; <code>afterprint</code> takes the portal down, and a second request replaces the projection and prints again, so a browser that never fires <code>afterprint</code> cannot leave Print dead for the session. What prints is the version the surface has on screen: the print job carries the ready arm of the <code>ShownVersion</code> its surface already computed. Only an older version can be anything but ready, so Print is disabled while one is loading or could not be loaded rather than falling back to the current version. The ready signal is <code>ArtifactBody</code>'s <code>onPrintReady</code>: prose reports it on mount, and a deck reports it once every page image has decoded, because the dialog captures the document as it is at the moment it opens and paper that came out blank is not something a person can undo. A deck whose pages are not there prints its outline instead, one page of the deck per sheet where they are.</p> <p>A sheet holds the artifact and nothing else: the title as an <code><h1></code>, then the body as formatted. There is no institution mark, no kind and version line, and no Sources list; the sources are in the History drawer, where every kind keeps them. The heading goes on only where the body does not open with one of its own that says the title, which <code>bodyOpensWithTitleHeading</code> decides: every document the agent writes opens with an <code><h1></code> naming the artifact, and a heading above it would print the name twice. It reads the body with <code>DOMParser</code>, takes the first node that is not whitespace, and counts it as the title when it is an <code><h1></code> whose words match, ignoring case and the whitespace around them. A <code>pages</code> body gets no title heading either, because a deck's own first slide is its title and the heading would take a sheet of its own. The browser's printed strip of date, title, address and page number goes with <code>@page { margin: 0; }</code> in the print stylesheet, which Chrome and Edge honour; in Firefox and Safari the strip is the person's own print setting and no page can turn it off. With the sheet margin gone, a text body gets its margins back from <code>.artifact-print-sheet</code>, a print-only table whose <code>thead</code> and <code>tfoot</code> hold a 0.75 inch spacer row each and whose one body cell carries the same padding at the sides. A table's head and foot repeat on every sheet the table crosses, which is what makes those bands a margin rather than a one-off gap. A <code>pages</code> body stays outside that table, because one slide per sheet is a forced break and browsers do not agree about a forced break inside a table cell.</p> <p>On screen <code>.artifact-print-portal</code> is <code>display: none</code>; the <code>@media print</code> block in <code>styles/index.css</code> hides every other child of the body and relaxes the shell's fixed height so the article flows across as many pages as it needs. The same block paints the document white with a light <code>color-scheme</code> and forces the portal's own text to black, so a reader on the dark theme gets ink on paper rather than near-white text, or slate-950 behind it when they have left "Background graphics" on. <code>PrintControl</code> is the one control behind both surfaces: a menu of Print quiz and Print with answer key for a quiz, a plain button for every other kind that prints, and nothing at all for flashcards. Flashcards are studied rather than printed; the cards leave through the Download menu, which has Anki and Quizlet as plain anchors to the export route. Everything else leaves through Export, which opens the menu described under "Export" below.</p> <h3 id="the-library-and-the-full-page">The library and the full page<a class="headerlink" href="#the-library-and-the-full-page" title="Permanent link">¶</a></h3> <p><code>/artifacts</code> (<code>ArtifactsRoute</code>, a lazy chunk) is the library: a <code>DataTable</code> of every artifact the person owns with a kind filter, an Actions column and "Load more". The Actions cell is one <code>Menu</code> labelled <code>Actions for <title></code> holding Rename and Archive. It opens with <code>strategy="fixed"</code>, which portals the panel to the body and places it from the trigger's measured box, because the table's own <code>overflow-x-auto</code> would clip an in-tree panel; a panel placed that way cannot follow its trigger, so a scroll or a resize closes it.</p> <p><code>/artifacts/:artifactId</code> (<code>ArtifactPageRoute</code>, a lazy chunk) is the reading-width page, and it carries what the panel carries with History in place of the panel's Versions menu. Its controls are one row of glyphs that does not wrap - History, Edit, Print, Export, a flashcards deck's Download and a quiz's display toggles - with Restore keeping its words while an older version is on show, and <code>To conversation</code> as a worded link beside them. <code>All artifacts</code> is not in that row: it is a <code>BackLink</code> (<code>components/ui/BackLink.tsx</code>) above the title, the same chevron-and-words control the knowledge folder detail page uses to get back to its list. It renders the same live <code>ArtifactBody</code> the panel renders, so a quiz is taken, flashcards are studied and a deck's pages are shown on the page as well, keyed by artifact and version so moving to another version starts the attempt over. The document is the only thing in the reading column: everything about the artifact that is not the artifact is behind History. A title link in the library opens the page with its <code>h1</code> focused, and All artifacts hands focus back to that link, through <code>lib/viewFocus.ts</code> (see <a href="../spa-design-system/#focus-between-an-opener-and-the-view-it-opens">focus between an opener and the view it opens</a>). A source link on the page opens the cited file in the knowledge file viewer the app shell draws, because the page has no panel of its own; the viewer takes focus on its heading, hands it back to the link on close, and closes when the reader leaves the page.</p> <p>History opens <code>HistoryDrawer</code> (<code>components/panel/HistoryDrawer.tsx</code>), a right-side drawer titled with the artifact's title over the kind and version line the page header uses, holding three stacked sections headed with their counts. Versions lists every version newest first with the label the dropdown uses, and picking one shows it on the page and closes the drawer, because the reason to pick a version is to read it. The row for the version on show carries <code>aria-current</code> and a heavier face, and picking that row only closes the drawer: reporting a version the page is already on would push a URL entry the browser's Back then has to be pressed twice to get past. Exports is <code>ExportRows</code> (<code>components/panel/ExportRows.tsx</code>), which polls while anything is unfinished and says "Nothing exported yet." when there is nothing; a kind whose traits say it does not export gets no Exports section at all, and the drawer does not ask for a list it could not show. It spins while the first answer is on its way and says "The exports could not be read." when the request failed, so a list that could not be read never passes for an empty one. <code>exportsRefetchInterval</code> stops the polling on that failure, because re-asking every two seconds for as long as the drawer is open never comes back with more than the first attempt did. Sources is the shown version's own sources, so the drawer credits what is being read rather than what is newest. An older version and its sources arrive on their own, so the section spins while that is happening and says "This version could not be loaded." when it did not arrive. The drawer is the <code>Drawer</code> primitive opened non-modal, so it is not a <code><dialog></code>: the document stays readable behind the scrim while the reader looks something up. Nothing holds the keyboard inside it either, so Tab past the drawer's last control lands on the page behind the scrim, which is the trade taken deliberately for a panel the reader leaves by Escape, by the scrim or by Close. Escape, the scrim and Close all close it, focus moves to its Close button on open, and the primitive hands focus back to the control the surface names in <code>returnFocusTo</code> - the page names its History button, because Safari does not focus a button on a mouse click and what had focus when the drawer opened is not reliably the opener. The panel keeps its Versions dropdown instead, because the panel is already a narrow column beside the chat and a drawer over it would cover the thing it explains. The version on show is <code>?v=<n></code> in the URL, so a version is a place that can be linked, reloaded and walked back through with the browser's own Back; absent, unparseable, or equal to the current version all read as current. Edit happens in place: the page wraps itself in the same <code>ArtifactDraftProvider</code> the panel uses, the title becomes an input in the <code>h1</code>'s place, the kind line becomes the draft's status, and the lazy editor chunk sits in the article's own measure. Both frames mount the chunk through <code>components/panel/artifactEditorChunk.ts</code>, and the editor brings no scroller or measure of its own, so it fits whichever one holds it. A reload mid-edit drops back to reading, and the next Edit resumes from the draft the server kept - <code>useArtifactDraft</code> seeds from <code>artifact.draft</code> whenever one is open. Edit focuses the <code>h1</code> around the title editor, not the title's own button, which turns into an input on every rename. A screen reader announces that <code>h1</code> by the button's name, "<title>, rename". Done hands focus back to Edit. <code>QuizDisplayControl</code> (<code>components/panel/QuizDisplayControl.tsx</code>) draws Show as text and Answer key in both places, reading and writing the display of the scope it is rendered in through <code>useQuizDisplay</code> and <code>usePanelActions</code>. The setting belongs to the surface the quiz is open on - a conversation's panel, or the artifact page - so a turn ending in another chat, which opens and closes that chat's panel, cannot hand a reader on the page back to question one. The page is keyed by the artifact and forgets its display through <code>forgetQuizDisplay</code> when it unmounts, so the next quiz opens to be taken rather than in the last one's answer key. The surface comes from <code>PanelScopeProvider</code>'s <code>surface</code> prop, which also decides where a source opens (see <a href="../spa-design-system/#the-panel-scope">the panel scope</a>). Its <code>variant</code> is only where it sits: <code>line</code> is small grey words in the panel's kind line, <code>icon</code> is a glyph in the page's control row, and a pressed toggle inverts to the ink colour in both. The page redirects to chat when the flag is off, the same as the library.</p> <h2 id="the-drawn-pages">The drawn pages<a class="headerlink" href="#the-drawn-pages" title="Permanent link">¶</a></h2> <p>A deck is read as pages rather than as text, so every version of a <code>slides</code> artifact is drawn the moment it lands. <code>RENDERED_KINDS</code> (<code>services/artifacts/renders/service.py</code>) is the set of kinds that get a render; nothing else does.</p> <p><code>queue_render(version)</code> creates the <code>ArtifactRender</code> row inside the version's own transaction and registers a robust <code>transaction.on_commit</code> hook that publishes an <code>ArtifactRenderMessage</code> on the interactive queue. A message can therefore never name a row the database has not got, and a queue that refuses fails the render row rather than the version: the deck is written either way, and a render that could not be queued is one the person can retry. <code>ArtifactService</code> calls it from <code>create</code>, <code>_append_version</code> and <code>restore</code>, so an assistant write, a person's promoted draft and a restore all draw.</p> <p>A deck that cites more sources than one page holds gets as many Sources pages as they need, so the placeholder count can be one or two short of the real one while the render runs.</p> <p>The job (<code>renders/job.py</code>) is an adapter the shared runner drives (<code>services/artifacts/jobs.py</code>, below): it builds the PowerPoint file with <code>build_deck</code>, converts it to PDF inside a conversion slot, and rasterises each page with PyMuPDF at 144 dots per inch, which is 1920 by 1080 for a 13.333 by 7.5 inch slide. Opening the PDF, drawing every page and storing each one is a single call off the ORM thread, and the document is closed before it returns, so a PyMuPDF document never outlives the thread that opened it. Each page is stored at <code>artifacts/{artifact_id}/v{n}/pages/{i}.png</code>, a key of the version rather than of the render row, so a second run of the same version overwrites rather than orphaning; that is why a worker finishing late behind a retry is harmless and the retry does not have to wait for it. The row then reads ready with its page count, and the time it took is logged and recorded as <code>artifact.render.slides</code>.</p> <p>Reads apply the deadline rather than sweeping the table, the way the export service does: a row still queued or running <code>JOB_DEADLINE</code> after it was last queued reads as failed with "Nothing drew this deck in time.", and a worker that claims one that late marks it timed out rather than drawing pages the person was already told had failed. The clock is <code>queued_at</code> rather than <code>created_at</code>, so <code>RenderService.retry</code> - which takes a failed row, or any row past its deadline - gets a full deadline of its own. A transient fault goes back on the queue while the message's attempts last; anything else fails the row with "The deck could not be drawn." and no traceback in it.</p> <p>The page route serves the object inline as <code>image/png</code> with <code>Cache-Control: private, max-age=31536000, immutable</code>, because a page is the drawing of one immutable version and never changes under the URL that names it. The pages are a function of the version's content alone - no institution name or logo is drawn on any slide - so a second drawing of a version puts the same bytes under the same key and a browser holding the first is holding the right one. It writes no audit row: a page view is a read of an artifact the caller already owns.</p> <p><code>renders/__init__.py</code> never re-exports <code>job</code>, for the reason <code>exports/__init__.py</code> records: the web image has neither <code>python-pptx</code> nor <code>pymupdf</code>. <code>render_deck_samples</code> is the management command that writes both a capped and a realistic deck per theme as PNGs, which is how the design is judged against real output rather than against the code.</p> <h2 id="export">Export<a class="headerlink" href="#export" title="Permanent link">¶</a></h2> <p>An export sends one version of one artifact somewhere: back to the person as a file, into their Google Drive or OneDrive, into a Canvas course as a page, a file or a quiz, or into one of their own knowledge folders. <code>apps/main_app/services/artifacts/exports/</code> holds the whole of it and <code>ArtifactExport</code> (<code>models/artifacts.py</code>) is the one row.</p> <h3 id="the-row">The row<a class="headerlink" href="#the-row" title="Permanent link">¶</a></h3> <p><code>ArtifactExport</code> carries the <code>destination</code>, the <code>format</code>, the <code>version_number</code> it named, the person who asked, the <code>connection</code> or <code>knowledge_folder</code> it is going to, the request's own <code>params</code>, a <code>status</code> of queued, running, ready or failed, and the <code>queued_at</code> the deadline counts from. Which columns are meaningful depends on the destination, the format and the status, and every one of those rules is a check constraint rather than writer discipline: <code>file_key</code>, <code>content_type</code> and <code>size_bytes</code> exist only on a ready download, <code>error_kind</code> and <code>error_message</code> only on a failed row, <code>external_url</code> only where something was made at a provider, and <code>finished_at</code> exactly when the row is ready or failed. <code>external_url</code> is allowed on a running row as well as a ready or failed one, because delivery records it the moment the provider answers; a queued row may not carry one, since nothing has been asked of a provider yet, and a download may not carry one at all.</p> <p><code>EXPORT_FORMATS</code> is the table of legal <code>(destination, format)</code> pairs, and it is the single source of two things: the request union's literals and the row's check constraint. The API therefore cannot name a pair the database would refuse, and neither can drift from the other. Each tuple is in the order the menu offers it, once <code>formats_for(destination, kind)</code> has dropped the formats this artifact's kind has no use for.</p> <table> <thead> <tr> <th>Destination</th> <th>Formats</th> </tr> </thead> <tbody> <tr> <td>Download</td> <td>PowerPoint, PDF, Word, HTML</td> </tr> <tr> <td>Google Drive</td> <td>Google Slides, Google Doc, PowerPoint, Word, PDF</td> </tr> <tr> <td>OneDrive</td> <td>PowerPoint, Word, PDF</td> </tr> <tr> <td>Canvas</td> <td>Canvas page, Canvas quiz, PowerPoint, Word, PDF</td> </tr> <tr> <td>Knowledge folder</td> <td>PowerPoint, Word, PDF</td> </tr> </tbody> </table> <p><code>_KINDS_BY_FORMAT</code> is that narrowing, and it is what both <code>formats_for</code> and the request-time <code>check_kind</code> read, so the menu and the refusal cannot disagree. Only a slide deck becomes a PowerPoint file or a Google Slides deck, only a document becomes a Canvas page, only a quiz becomes a Canvas quiz, and flashcards have no export format at all, because they leave through their own Anki and Quizlet downloads. A PowerPoint file is the deck's own slides, drawn by <code>python-pptx</code> on a blank layout, and the deck's PDF is Impress's conversion of that same file, so the two cannot disagree about where a line breaks. A Google Slides deck is that PowerPoint file with Drive asked to convert it, the way a Google Doc is the Word file converted.</p> <h3 id="making-the-file">Making the file<a class="headerlink" href="#making-the-file" title="Permanent link">¶</a></h3> <p><code>ArtifactExportService.request</code> promotes any open draft first, so the export names a version holding what the person is looking at, then either finishes the export inline or queues it. An HTML download is made inline, because it needs no LibreOffice, and a second request for the same version, format and answer-key choice returns the row that already exists rather than making the file twice. Every other format goes on the interactive queue as an <code>ArtifactExportMessage</code>, which the document worker takes beside its ingestion messages; a person is watching a status card for it, so it is queued the way a chat attachment is.</p> <p>One download of a file may be in flight at a time, and the database is what holds that rather than a read the request path does first. <code>artifactexport_one_live_download</code> is a partial unique index over the four things that decide which file a download produces - the artifact, the version, the format and the answer-key choice - across the rows that are still being made, so two clicks landing together cannot both become a row. The request that loses reads the live row back and returns it, and the person watches the file the first click is making. A live row nothing is coming for - one past <code>JOB_DEADLINE</code> whose message was lost - is failed as timed out first and the new row takes its place, so "Try again" on a dead row is never refused forever. A third request that takes the freed slot in between is treated exactly like the first loser: the insert collides a second time, and the request is answered with the row that now holds the slot rather than with an error, since that row is making the file it asked for. Rows whose <code>params</code> predate the answer-key choice read as null in the index, and a unique index compares no nulls, so they are outside it.</p> <p>The queue send and the bucket put on this path run on bounded clients (<code>clients/aws.py</code>), because a person is waiting on them. An unreachable queue or bucket ends the request in seconds with a sentence rather than holding the connection open for botocore's default minutes, while the worker's own poll keeps the unbounded client, since a read timeout shorter than its long poll would turn every quiet cycle into an error. A bucket that will not take an inline export's bytes fails the row as <code>storage</code>, a kind of its own: nothing about the artifact is wrong, and the same request works once the bucket answers again.</p> <p>The job (<code>exports/job.py</code>) is an adapter the shared runner drives (<code>services/artifacts/jobs.py</code>, below), so two deliveries of the same message cannot both build it. <code>render_export_document</code> builds the HTML - the title as an <code><h1></code>, then the body, and nothing around them - and <code>convert_document</code> runs LibreOffice headless once for Word and twice for PDF, so both files come from one Writer document and cannot disagree. It leaves that heading out where the body already opens with one that says the title, the rule paper follows above, decided here by <code>_body_opens_with_title_heading</code>; the <code><title></code> in the head, which is the tab's name rather than a heading on the page, is written either way. Two conversions run at a time, because each one spawns a <code>soffice</code> of its own, and <code>conversion_slot()</code> (<code>services/artifacts/jobs.py</code>) is the only way to take one of the two. A job holds its slot for its whole chain of conversions, so a PDF's two LibreOffice runs are one wait rather than two. A job that finds both slots taken waits up to <code>SLOT_WAIT</code> and then goes back on the queue having spent none of its attempts, because more requests than slots is load rather than a fault; the row keeps the deadline it was queued with while it waits, so a process whose slots never come free ends at <code>JOB_DEADLINE</code> instead of queueing forever. A conversion that hangs is given <code>CONVERSION_TIMEOUT</code> and is then killed with everything it started: soffice is a tree of processes, and the ones left behind would hold a CPU and a slice of the worker's memory until the task ran out of it. A ready download's file lands under <code>artifacts/{artifact_id}/v{n}/{export_id}.{ext}</code> and is served by the download route, which audits the download itself.</p> <p>A row that has sat longer than <code>JOB_DEADLINE</code> since it was last queued reads as timed out to the card and the history whether or not a worker ever picked it up, and a worker that claims one that late marks it timed out rather than finishing an export the person was already told had failed. A transient fault goes back on the queue while the message's attempts last; a fatal one fails the row with a sentence the person can read and no traceback in it. Building the file and putting it in the bucket both happen off the worker's shared ORM thread, and only the row writes run on it, so a slow conversion never holds up another message's reads.</p> <h3 id="sending-it-somewhere">Sending it somewhere<a class="headerlink" href="#sending-it-somewhere" title="Permanent link">¶</a></h3> <p><code>exports/deliver.py</code> has one arm per destination, each ending in a link the status card can open. The row is the only record of what a provider made, so every arm writes that link onto it the moment the provider hands one back and before it asks that provider for anything else. The write is a compare-and-swap from running to running, so it lands only while this delivery still owns the row, and a worker that dies immediately afterwards leaves a row that still points at what exists. The three connector destinations go through the shared connector gate (<code>connectors/services/execution.py</code>), so an export is governed exactly like a write the agent proposes: the connector has to be offered, its audience has to admit the person, and the institution has to have enabled the action. The file the connector uploads is bound to the action context as a <code>FileRef</code> the resolver answers for, so a connector never reads storage itself. The knowledge folder is not a connector at all: it is the folder upload sequence, so the file ingests exactly like one somebody uploaded (<a href="../document-pipeline/">document-pipeline.md</a>). Those steps are not one transaction, so a step that fails after the bytes are in the bucket takes them back out and hands the row that named them to the registrar's <code>discard</code>, the faithful inverse of the <code>reserve</code> that made it: a folder showing a file whose bytes are gone is worse than a folder that never heard of it. The row goes whether or not the bytes did, because the row is what the folder shows and nothing is ever going to ingest it. A delete that itself fails is logged by the class of the fault alone and the original failure is what the row carries, because that is the one the person asked about. Drive is told what to make of the file it is given: <code>_drive_conversion</code> passes <code>convert_to</code> as <code>google_doc</code> for a Google Doc, <code>google_slides</code> for a Google Slides deck, and <code>file</code> for everything else, which stores the upload as it is.</p> <p>Nothing inside delivery is retried. A rate limit on the third question of a Canvas quiz, retried, would leave a second quiz behind, so a failure inside delivery fails the export with its own sentence and stops. The connection is read again at delivery, because it may have been removed or have expired while the export sat on the queue; an expired one fails with its own error kind so the card can say "Reconnect in Settings" rather than offering a retry that must fail the same way.</p> <p><code>exports/destinations.py</code> answers what this person can do with this artifact right now, and the menu draws itself from that: an offered destination carries its formats and the connections or folders to choose between, and one that is not offered carries the reason - no connection yet, a connection that needs reconnecting, an action the institution has not enabled, or a feature that is off for them. It asks the same governance the delivery leg enforces, so nothing is offered that is already refused, though a destination shown as available can still fail at delivery when an admin disables an action in the meantime.</p> <h3 id="the-canvas-quiz">The Canvas quiz<a class="headerlink" href="#the-canvas-quiz" title="Permanent link">¶</a></h3> <p>A Canvas quiz is a Classic quiz created unpublished, then filled one question at a time (<code>create_quiz</code> and <code>add_quiz_question</code> on the Canvas connector), and <code>exports/canvas_quiz.py</code> is the mapping. Canvas grades what it is sent, so the mapping is the grading: a weight of 100 on every correct answer and 0 on the rest is what makes multiple choice and multiple answers grade themselves, and true or false sends the two answers Canvas grades on by name. An option's text is sent as plain text because Canvas shows <code>answer_text</code> as it is, while the stem keeps its HTML, which Canvas renders.</p> <p>The artifact's short answer is self-graded against a model answer, which Canvas has no equivalent of - <code>short_answer_question</code> grades on an exact string match and would mark a correct answer wrong - so it becomes an essay question, marked by hand, with the model answer as the feedback every student sees once they have answered. A hint has no Canvas home and is dropped rather than smuggled into that feedback, where it would arrive too late to help. A quiz that fails part way keeps the link to what Canvas already made, and the row says how many questions landed so the person can open it and finish or delete it. The row names the quiz from the moment Canvas creates it, before the first question is sent, so a worker killed inside the loop still leaves the quiz findable rather than sitting unnamed in the course.</p> <h3 id="the-export-menu">The export menu<a class="headerlink" href="#the-export-menu" title="Permanent link">¶</a></h3> <p>The Export control is a menu of destinations, each opening a submenu of the formats it takes. Choosing a format is the whole of the common case: a download, or a drive the person has exactly one account on, starts on the click, because there is nothing left to ask. The menu draws itself from <code>GET /api/artifacts/<id>/destinations</code>, fetched the first time it is opened, so a toolbar that is only ever read costs no request; a destination the person cannot use is a note row carrying its reason ("Connect Google Drive in Settings.", "Not allowed by your institution."), because a menu that hides half of what exists explains nothing. A note is the menu's row for anything that says something rather than doing it - that reason, the spinner while the list loads, the sentence when it fails - and the keyboard walk steps over it, because there is nothing there to choose. The three provider marks are bundled SVGs under <code>web/src/assets/brands/</code>, so the menu draws itself whether or not Google's, Microsoft's and Instructure's CDNs are reachable; Download and Knowledge folder use the app's own glyphs. A quiz's answer key is a menu item rather than a checkbox: every file format appears twice, "PDF" and "PDF with answer key", and a Canvas quiz appears once, because it carries its correct answers and feedback as data.</p> <p><code>ExportPicker</code> opens only for what a menu cannot hold: which Canvas course, which knowledge folder, which of two connected accounts, and whether a Canvas quiz is practice or graded. Its button names the outcome in full - "Create an unpublished page in CS101", "Add a Word file to Biology notes" - and is the confirmation for the whole export, so nothing asks the person to approve the same thing twice. The Canvas course list is a live call to Canvas (<code>/api/artifacts/exports/canvas-courses</code>, filtered to the courses the person teaches), so the picker shows the course select only once Canvas has answered, and a failed answer is shown as the server's own sentence rather than as an empty course list. Pressing the button starts the export and closes the picker: nobody is held in a dialog while a file is being made.</p> <p><code>ExportStatusCards</code> (<code>components/panel/ExportStatus.tsx</code>) is where an export reports itself: a card in the bottom corner of the viewport, one per export started in this session. The cards live in a module-level store and are portalled to the body, and they are mounted once at <code>RequireAuth</code>, above every signed-in route, so a card started in the conversation panel is still there after the person opens the artifact's full page or takes a trip through Settings. The store owns the request as well as the card, first attempt and Try again alike: an export happens at a provider, so the POST is a store action rather than something a component does on mount, which would send a second export every time that component remounted. An export crosses the wire as <code>ArtifactExportDetail</code> (<code>apis/artifacts_api.py</code>), one of five variants that each carry only their own fields beside the ones every export has: <code>ExportQueued</code>; <code>ExportRunning</code>, with the provider's link once it has returned one; <code>ExportReadyDownload</code>, with the file's <code>file_url</code>, <code>filename</code> and <code>size_bytes</code>; <code>ExportReadyDelivered</code>, with the <code>external_url</code> of what the provider made; and <code>ExportFailed</code>, with its <code>error_kind</code>, its <code>error_message</code> and any link to what was already made. <code>status</code> names the variant, and a ready row is split again on <code>destination</code>, so the card and the history switch on those two fields and read what they find with no null check. The discriminator is a callable over the two fields rather than a union nested inside the <code>ready</code> arm, because the pinned <code>openapi-typescript</code> cannot read the mapping Pydantic writes for a nested union; the callable form comes out as a plain <code>oneOf</code> of the five, which the generator renders as an ordinary TypeScript union. The shared export-list query polls every two seconds until every row in the list is terminal; a card watches its own row, and when that row settles it offers the file to download or the link to open, and starts the thirty-second close. A failure shows the row's own sentence with Try again, except an expired connection, which sends the person to Settings; a request that never landed shows what the server refused, and a list that cannot be read says so rather than spinning. A failed row that carries a link shows it under one fixed sentence saying something was already made at the destination and may be incomplete, whatever the card offers next: above the Try again that would make a second one, and between the failure and the sentence sending the person to Settings, because every way an export fails can leave something behind and the person has to be able to find what exists before they decide. A settled card closes on its own button, or thirty seconds after it settles unless the pointer is on it or the keyboard is in it. The Exports section of the full page's history drawer lists every export of the artifact with the same links.</p> <h2 id="the-workers-side-of-both-jobs">The worker's side of both jobs<a class="headerlink" href="#the-workers-side-of-both-jobs" title="Permanent link">¶</a></h2> <p>An export and a render are the same job over two different rows, so <code>services/artifacts/jobs.py</code> holds one runner and each job hands it an adapter: <code>load</code>, <code>status</code>, <code>is_stale</code>, <code>claim</code>, <code>build</code>, <code>finish</code>, <code>requeue</code>, <code>fail</code> and <code>metric</code>, typed to that job's own row and to what its build produces. <code>run_job</code> owns the order of events and the exception ladder under it, which is what keeps the two from drifting apart the way two written-out copies of the same job did. It answers the worker with one of three things - <code>Completed</code>, <code>InFlight</code> or <code>Retry</code> - and the worker's ladder over them ends in <code>assert_never</code>, so a fourth outcome is a type error rather than a message quietly left on the queue.</p> <p>The claim is a ladder with five ends: a row the artifact's deletion took with it, a row already ready or failed, a row past its deadline, which the runner fails as timed out rather than building, a row a peer holds inside its deadline, which leaves the message on the queue for a later delivery, and otherwise the row this delivery owns.</p> <p>Every status write goes through <code>swap_status</code>, a compare-and-swap that updates the row only while it still holds a state the write makes sense from, and that clears the error columns on every write that is not a failure, so a row can never read ready while still carrying why it failed. A finish also accepts a row already failed with exactly the timeout, because the work behind that timeout was real: a worker that finishes just after a redelivery timed its row out still lands its file or its pages. Of the two writes only one lands, and the one that loses changes nothing at all and reports its job as not ok. A failure writing over a row never blanks an <code>external_url</code>, because a half-made quiz at a provider is still there whatever the row says.</p> <p><code>services/artifacts/timing.py</code> states the timing set once: how long a conversion may run, how many conversions a job chains, how many run at a time, how long a job waits for a slot, what the interactive queue's visibility timeout is, how often the worker extends it, and <code>JOB_DEADLINE</code>. The figures only mean anything against each other, so <code>tests/test_artifact_timing.py</code> checks the arithmetic - an attempt fits inside one delivery of its message, the deadline covers the queue's three deliveries, the heartbeat beats the timeout - and reads the visibility figure back out of <code>infrastructure/base/sqs.tf</code> and <code>infrastructure/elasticmq/elasticmq.conf</code> so this copy of it cannot drift from what the queues are configured with. The conversion timeout itself is stated where it is enforced, on the converter (<code>content_extraction/converters/libreoffice.py</code>), and <code>timing.py</code> reads it from there rather than keeping a second copy.</p> <p><code>JOB_DEADLINE</code> is fifteen minutes, and it is the one deadline both rows are read by. It counts from <code>queued_at</code>, so a row the worker puts back on the queue gets a full deadline of its own rather than the remains of the first one, except a row that only waited for a conversion slot, which keeps the deadline it had. Three deliveries of a message at the interactive queue's 300 second visibility timeout fit inside it, so a worker that dies holding a row leaves the queue its whole redrive budget before anything calls the row timed out.</p> <p>While an artifact job runs, the worker hides its message again every <code>HEARTBEAT_INTERVAL</code>, each extension good for the whole visibility timeout counted from then, so a conversion that outlasts one timeout is not handed to a second worker. The heartbeat stops when the job returns, and it stops at <code>JOB_DEADLINE</code>, because a job still running then is wedged and its message has to come back for a later delivery to time the row out and for the queue to move the message on. An extension that fails ends the heartbeat and nothing else, logged once by the class of the fault: the job owns the row, and the worst a lost extension costs is a redelivery the claim already handles.</p> <p>The worker runs every message through one shared ORM thread, so the runner puts only the row reads and the row writes on it and keeps each of them short. Everything a build does that takes time - the conversion, the drawing, the bucket puts, the delivery - runs off that thread, and nothing running off it touches the ORM.</p> <p>The fleet those workers run in is sized by one target-tracking policy on the worker service, and its metric counts the interactive queue as well as the bulk one (<code>infrastructure/app/worker.tf</code>). An export or a render is queued the way a chat attachment is, so a policy counting bulk alone would hold the fleet at one task while a rush of exports waited behind the two conversion slots that one task has. Both queues also carry a depth alarm and a dead-letter alarm (<code>infrastructure/app/monitoring.tf</code>), so a queue filling up and a message that failed every delivery each reach Slack and email rather than only the person whose card never settles.</p> <h2 id="audit-and-metrics">Audit and metrics<a class="headerlink" href="#audit-and-metrics" title="Permanent link">¶</a></h2> <p><code>AuditAction</code> has <code>ARTIFACT_CREATED</code>, <code>ARTIFACT_UPDATED</code>, <code>ARTIFACT_RESTORED</code>, <code>ARTIFACT_RENAMED</code>, <code>ARTIFACT_ARCHIVED</code>, <code>ARTIFACT_EXPORTED</code> and <code>ARTIFACT_DOWNLOADED</code>; the service writes one per action with the artifact id, kind and version number, never the body (<a href="../audit-logging/">audit-logging.md</a>). A promoted draft is an <code>ARTIFACT_UPDATED</code> row with <code>source: person</code>; an export is written where the export is requested with the version and the <code>format</code>, and <code>ARTIFACT_DOWNLOADED</code> where the file is served, so asking for a file and taking it are two separate facts. A delivery to a connector also writes the connector's own <code>connector_action_requested</code> row with <code>artifact_export</code> as its source. Draft saves are not audited: the promotion is the durable event. The tools count <code>artifact.created.<kind></code>, <code>artifact.updated</code>, and <code>artifact.validation_failed.<create|update></code> through <code>MetricsService</code>, and the job records <code>artifact.export.<format></code> in milliseconds on a successful export only.</p> <h2 id="what-later-items-add">What later items add<a class="headerlink" href="#what-later-items-add" title="Permanent link">¶</a></h2> <ul> <li>Images in documents, edit operations, sharing beyond the owner, persisted study history (#466).</li> <li>A mind map kind, which this branch's history at <code>0159c2bb</code> holds a first version of.</li> <li>A slide editor built as a form per layout, and with it the Edit button on a deck.</li> <li>An image layout and an image source for a deck, once the five layouts have proved themselves.</li> </ul> </article> </div> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type="button" class="md-top md-icon" data-md-component="top" hidden> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Back to top </button> </main> <footer class="md-footer"> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class="md-copyright"> Made with <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener"> Material for MkDocs </a> </div> </div> </div> </footer> </div> <div class="md-dialog" data-md-component="dialog"> <div class="md-dialog__inner md-typeset"></div> </div> <script id="__config" type="application/json">{"annotate": null, "base": "../..", "features": ["navigation.expand", "navigation.top", "navigation.instant", "navigation.tracking", "content.code.copy", "search.highlight", "search.suggest", "toc.follow"], "search": "../../assets/javascripts/workers/search.a7633da7.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> <script src="../../assets/javascripts/bundle.c21346df.min.js"></script> <script src="https://vsu-troy-pilot.campuscoreai.com/widget/v1.js" async></script> <script src="../../javascripts/deploy-role-link.js"></script> </body> </html>