Receipts everywhere (AR-1), then patterns stored as declarations (AR-2)
Measured by REUSE_LEG_PROTOCOL.md; sequenced against HyperFrames in HYPERFRAMES_SCORECARD_PLAN.md (owner, 2026-10-26).
Status: plan for owner approval. Nothing here is built. Order set by the owner, 2026-10-26: “plan AR-1 receipts first, then AR-2”. Source: Muse’s architectural review (2026-10-25, MCP server 1.6.23 at d785d14). The facts below were read from origin/main of three repos on 2026-10-26: pineapple (engine), mcp-server (1.6.23) and mcp-cloud.
What this is for: an agent that is told success:true must be able to trust it. Today the expensive failures are
the ones that look like successes: an argument dropped, swapped or applied to the wrong item, found only in exported
pixels. The readers are agents (the MCP surfaces, local and cloud), the beta battery, and through them every
customer whose render is built by an agent. AR-2 makes the graph cheaper than enumeration, which is what makes the
declarative surface worth using.
Part 1 — AR-1: receipts
1.1 What exists today (measured, not assumed)
There are pieces of a receipt in all three repos, and none of them is complete.
| Where | What it reports | What it misses |
|---|---|---|
mcp-server, every tool (handlers.ts:1889-2007) |
top-level argument names not in the published schema: IGNORED ARGUMENTS: … and _meta['pinepaper.studio/ignoredArguments'] |
nested keys: operations[i].*, keyframes[], params, items[].params |
mcp-server, create/modify/batch (code-generator.ts:556, 1329, 2784, 2833) |
ignoredProperties for unknown properties keys |
values: an out-of-vocabulary value with a known key (textAlign:'justify') passes |
| mcp-server, zod | — | plain z.object strips unknown keys with no record: KeyframeSchema (370) drops ease for easing; AddRelation, KeyframeAnimate, AgentExport and the batch item schemas strip too |
mcp-server, add_relation |
refusals (relationRefusalReasonJs) |
unknown keys in params (a z.record) are stored and ignored |
mcp-server, agent_export (~4493) |
fidelity.warnings for video |
its own executeCode never reads run.report: engine warnings for exports are lost |
engine noteWarning (PinePaper.js:24281) |
{message, ...detail} onto one instance-wide list, capped at 200 |
not tied to the call: only GeneratedCodeRunner drains it, and it throws away the backlog at the start of each run. Calls through the direct API, CloudBridge or AgentMode lose their warnings |
engine normalizeParams (694-838) |
_ppColorRefused only |
eight silent rewrites: fill→fillColor, position→x/y, delay→timeOffset, ms auto-detect, radiusX→width… |
engine create/modify |
refused colours, an unavailable font | unknown keys silently ignored; bad strokeWidth/strokePosition/fit only console.warn, which drop_console strips in production |
engine addAnimation (14737) |
{replaced, droppedProperties, clipWindow…}: the closest thing to a receipt |
unknown keyframe property names are stored and ignored |
engine addRelation / addAssociation |
alias, refused params (2 relation types only), unresolved endpoints | unknown param keys stored; a duplicate relation Object.assigns silently; '#item_2' rewritten silently |
cloud canvasReceipt (canvas-render.ts:96-137) |
replays the canvas with and without the last op; new dangling refs or style refusals → dropped |
only on the generic path; cloud-implemented tools return before it (mcp.ts:3214); applied is the tool name only; applied/dropped are not copied into _canvas |
| cloud render door | ExportReport.dropped{what,why} (embed formats only), job warnings, findings STATIC/BLANK |
video/still exports have no per-parameter receipt |
The review’s evidence, checked against code:
| Finding | Status in code |
|---|---|
#94 textAlign:'justify' |
confirmed, three different silent treatments: create → center (2492), modify → passed to Paper unchecked (16789), keyframe → dropped (KeyframeInterpolator:85) |
| #120 render hook id re-registered | confirmed: addOnFrameCallback replaces with console.warn only (16137); registerRenderHook overwrites (~15737) |
PLATFORMX23 platform:'tiktok' |
confirmed: the preset sets 1080×1920 (AgentMode:545), _exportMP4 passes neither (1050) |
#104 GIF scale |
confirmed, and worse than reported: dropped always, not only without loop (AgentMode:912, VideoExporter:3014). GIF quality only changes fps |
| #109 APNG 0/0 delays | refuted in code: the delay is 1000/round(fps×1000) (APNG.js:77). The real edge is a clamp above ~65 fps. Needs the tester’s file to settle |
| STROKEKEY23 text stroke drops fill | not found: fill and stroke are set independently, and a wide centred stroke eats into the glyph. Probably a perception of the stroke covering the fill; needs the tester’s repro |
| #115 shadow on a masked import | inferred, not verified: shadow is set on the group with no mask check; probably clipped |
#102/#67 $N |
confirmed inconsistent: batch is 0-indexed and throws out-of-range at run time; standalone tools resolve against the PREVIOUS batch and otherwise fall back to the literal '$N' |
1.2 The receipt
One shape everywhere:
{
"target": { "id": "item_7", "name": "moon" },
"applied": ["fillColor", "x", "y"],
"transformed": [{ "path": "fill", "to": "fillColor", "rule": "alias" },
{ "path": "textAlign", "from": "justify", "to": "center", "rule": "out-of-vocab-fallback" }],
"dropped": [{ "path": "shadowOffsetX", "value": 40, "reason": "not-supported-on-masked-import" }]
}
- Empty arrays are omitted. A clean call adds about 40 tokens. Compact mode keeps only
droppedandtransformed. reasonandruleare codes from one published list (js/core/ReceiptCodes.js), each with a sentence.- Batch: each op carries its own receipt at
results[i].receipt. The top level aggregates them, paths prefixedoperations[3].properties.textAlign, and the totals are counted. - The invariant: a drop or transform that is not in the receipt is a test failure.
success:truestays true when something was declared; the receipt is where the agent reads it.
1.3 Design
Engine: a per-call collector (R0).
- Replace the instance-wide warning list with a scope stack:
app.beginReceipt()/endReceipt(). noteWarningwrites to the innermost scope, and still to the legacy list so nothing that reads it today breaks.- Each public door opens a scope:
- create, modify, addAnimation, addKeyframe, addRelation, applyTextStyle, applyEffect;
- the render-hook registrations;
- every export (quickExport, ExportEngine, VideoExporter).
normalizeParamsrecords its rewrites astransformed.- Unknown keys become
droppedwith the reasonunknown-property. Each door’s known-key list is built from what it actually reads; the mcp-server’sCREATE_KNOWN_KEYSandMODIFY_KNOWN_KEYSare moved into the engine as the single source. - Return types do not change (CLAUDE.md rule 8: callers rely on
createreturning the item andmodifyItemreturning a boolean). The receipt is read from:app.lastReceipt;- GeneratedCodeRunner’s
report.receipts[], one per door call, in order; - opt-in
{ receipt: true }on the door, which returns{ result, receipt }.
mcp-server (R2).
- Nested strip detection. After
schema.parse(args), diff the input against the parsed value recursively. Every key zod stripped becomesdroppedwithnot-a-parameterand its full path. This alone closeseasevseasing, nested batch keys and keyframe extras, with no change to any schema. - Merge the engine’s
report.receiptsinto the tool result’sreceipt. agent_exportreadsrun.reportlike every other tool.- The
IGNORED ARGUMENTStext line stays, generated from the receipt, so existing readers keep working.
Values, not only keys (R1). A known key with a value the door cannot honour is either applied or declared:
textAlign:'justify': declared astransformed→center(or supported — that is a separate fix the owner can order).- An out-of-range strokeWidth:
dropped, instead of aconsole.warnthat production strips. - A duplicate relation:
transformedwith the rulemerged-into-existing. A re-registered render hook id:transformedwithreplaced-previous(or refused; owner’s call, see 1.5).
Exports (R3, AR-7 in passing).
- Each format declares the options it reads.
quickExportreports every option it did not pass on: platform dimensions and GIF scale today.- The export receipt carries
lastVideoReportfor every format, not only MP4/WebM. deterministic:trueon H.264 is either honoured 3/3 byte-identical ortransformedwithnot-bit-exact-on-h264(B31).
Item references (R4, part of AR-4). Every receipt names the resolved target.
$Nthat resolves against a previous batch, or falls back to the literal string, istransformedwith the rulepositional-ref-previous-batch, or refused before any mutation.- Batch
$Nout of range is refused at preflight, not mid-run.
Cloud (R5).
- Wrap
MCP_TOOL_HANDLERSso cloud-implemented tools get the same receipt as the generic path. - Copy
applied/droppedinto_canvas.receipt. - Render jobs carry an export receipt for every format (extends
ExportReport). - No
MCP_TOOLS_REVISIONbump unless a schema changes.
1.4 Phases and tests
| Phase | Builds | Proved by |
|---|---|---|
| R0 | engine collector + ReceiptCodes; no behaviour change |
every existing test passes; one test per door that lastReceipt exists and is scoped to the call (two calls, two receipts, no bleed) |
| R1 | engine doors declare keys and values | one regression test per §1.1 evidence row: textAlign×3 paths, hook id, duplicate relation, unknown keyframe property, strokeWidth, #item rewrite; plus the shadow-on-mask case once verified in a browser |
| R2 | mcp-server nested strip + receipt merge + agent_export report |
ease→easing named at keyframes[0].ease; a nested batch key named at its path; a fuzz pass: every property in every create/modify schema is set once, and the receipt says applied or declared, zero silent |
| R3 | export receipts | per-option effect tests: platform 1080×1920 honoured or declared; GIF scale; H.264 determinism 3/3 or declared |
| R4 | target in every receipt; $N declared or refused |
the same name or $N hits the same item through batch, standalone and relation (cross-surface test) |
| R5 | cloud parity | a cloud-implemented tool and a generic one return the same receipt shape; a render of a scene with an unsupported field lists it |
R5 measured before building (2026-10-26). Generic tools reach the cloud through the vendored mcp-server and already
carry its top-level note (and R2’s nested one once 1.6.25 is vendored). The cloud’s own 84 tools
(src/handlers/mcp-tools/*) do not, and 73 of them pass args whole to a helper (validateScene(args), a scene used
as the arguments, a builder reading its own options), so the keys they read cannot be listed from the handler body.
A blanket check against the published schema would call working arguments ignored, which a receipt must never do. R5
is therefore per tool, in two steps: (a) the 11 tools that read named keys only get the note now, with a coverage test
like mcp-server’s; (b) the other 73 get it as each one’s accepted keys are declared beside its schema (AR-5’s
generated schemas are the durable answer). Owner’s call whether (b) waits for AR-5.
| Gate | CI invariant | a test fails if any door returns success with a drop absent from its receipt |
Verification against what is deployed, not mocks (CLAUDE.md §5): the R1–R3 tests run in Chromium on the built editor, and the battery’s evidence items are re-run by Muse on the release.
1.5 Decisions (owner, 2026-10-26: “1 declare, 2 declare for now, 3 refuse, 4 refuse”)
- Unknown keys: declared. A drop goes in the receipt and the call proceeds;
strict: true(per call or session) refuses instead. textAlign:'justify': declared for now. The fallback tocenteris atransformedentry; justify support is a separate text-layout change, later.- Re-registering a render hook id: refused. The second registration fails by name and the first hook stays.
$Nacross batches: refused. A$Nthat does not resolve inside its own batch is refused before any mutation.
Part 2 — AR-2: patterns stored as declarations
2.1 What exists today
- Already declarations:
- every relation, including
wave_through(sampled live; it has a signal form, RelationRegistry:3098/3499); time_expressionwith ExpressionIR,orbits,morphs_to;- generators;
- registered render hooks (as source);
- counters, emitters, lifetime;
- the score;
- chain branches (#29 §7, PR #193).
- every relation, including
- Expanded at authoring time (the expansion is what is stored):
staggerItemswritesanimationDelayper item and keeps no record of the stagger.- Choreograph is compiled by the MCP into keyframes.
- Letter-collage motion is stored per letter.
applyTextEffectwrites keys to every glyph, and stores the spec ashas_text_effect. It is half a pattern node already.- Cloud-side:
character_pose/character_animatestore manycreate_itemops with keys (deliberately, per its comment); slideshow, promo and product_promo store compiled timeline ops; photo_collage stores one big batch.
- The real cost is agent-side enumeration. The review’s 6–76× rows (SILKBANNER, NETWORK, the 60 s chunk) are the agent writing keyframes because no pattern says “a wave over these strips”. The cloud canvas stores the raw call log (
{tool,args,ts}in a Durable Object with no compaction), so an enumerated build is stored, and replayed, at full size.
2.2 Design: a pattern node
{ id: 'banner-wave', kind: 'phase_wave',
members: { names: ['strip-*'] } | { ids: [...] } | { tag: 'strips' },
params: { property: 'y', amplitude: 24, wavelength: 6, period: 2.4, phaseStep: 'index' },
window: { from: 0, to: 14 } }
- Evaluated per frame by the RelationRegistry, like
wave_through, with a pure signal form. That makes preview, every export and at-readback agree, and keeps it deterministic: noMath.random, and the seed is a declared param. - Stored as the node. It goes in the project document, templates and the cloud canvas. No keyframes are written. A receipt from AR-1 says
expanded: { members: 78, keyframes: 0 }. - Edited by parameter.
pinepaper_pattern { action: 'create' | 'modify' | 'retarget' | 'remove', id, … }. Changing the amplitude is one small call; retargeting ismemberschanged. - Members by name or tag need names to resolve everywhere. The minimal part of AR-4 comes with this:
create({ name })already setsdata.name, and a resolver used by patterns, relations and batch ops makes it addressable. Duplicate names are refused at create.
v1 kinds, from the battery’s probe set:
| Kind | Covers |
|---|---|
phase_wave |
SILKBANNER cloth; the tester’s “wave with phase = index × Δ” (#29 §2) |
stagger |
stored as a node; animationDelay becomes derived |
layout |
ring, grid, arc, forming over time |
parallax_set |
a set at depths, over the existing parallax relation |
Later, needing simulation with a declared seed: force_layout (NETWORK), flock, growth (HYPHAE). Strange
attractors are already declarable through equation_path.
Migrations without regressions:
- Old documents load unchanged. Keyframes stay valid, and enumeration stays allowed.
staggerItemsandapplyTextEffectkeep their output and also record their node, so a later edit is a parameter change.- Cloud character expansion is not touched in v1.
2.3 Acceptance: the review’s lifecycle protocol
Measured on SILKBANNER and NETWORK, tokens per phase, with discovery order noted:
| Phase | Target |
|---|---|
| Build | ≤ 2× the JS reproduction (SILKBANNER: ≤ ~7.6K tokens vs 137K today) |
| Edit (one parameter) | ≥ 10× cheaper than re-sending the enumeration |
| Retarget | ≥ 10× cheaper |
| Verify | read state at t without an export; needs AR-3, out of scope here |
| Storage | the saved file holds the node, and no per-member keyframes (asserted by inspection in the test) |
NETWORK reaches the target only when force_layout lands (later). Its v1 result is reported, not hidden.
2.4 Phases
| Phase | Builds |
|---|---|
| P0 | the name resolver (minimal AR-4) + duplicate-name refusal |
| P1 | the pattern node, phase_wave + stagger, engine signal form, persistence in the project document and templates |
| P2 | pinepaper_pattern MCP tool (mcp-server + cloud replay handler; MCP_TOOLS_REVISION bump) |
| P3 | layout, parallax_set; text effect and stagger record their nodes |
| P4 | the lifecycle benchmark on SILKBANNER (and NETWORK as it stands), published with the release |
Depends on AR-1 R0 (the collector), so a pattern reports its expansion and anything it could not honour.
2.5 Decisions for the owner
- Approve the v1 kinds, or name others from the battery first.
- Should a stored pattern be editable in the Studio UI in v1, or only through MCP and code? Proposal: MCP and code first.
Sequencing with what is in flight
- #29 (LONG_FORM_RELATIONS.md): its §2 “collection relations with per-member phase” is
phase_wave. Building P1 delivers it, so the two plans do not build it twice. - AR-3 (
/graph:query) and AR-5 (generated schemas) are not in this plan. R2’s known-key lists and P1’s nodes are the inputs they will need.