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 dropped and transformed.
  • reason and rule are 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 prefixed operations[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:true stays 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().
  • noteWarning writes 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).
  • normalizeParams records its rewrites as transformed.
  • Unknown keys become dropped with the reason unknown-property. Each door’s known-key list is built from what it actually reads; the mcp-server’s CREATE_KNOWN_KEYS and MODIFY_KNOWN_KEYS are moved into the engine as the single source.
  • Return types do not change (CLAUDE.md rule 8: callers rely on create returning the item and modifyItem returning 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 becomes dropped with not-a-parameter and its full path. This alone closes ease vs easing, nested batch keys and keyframe extras, with no change to any schema.
  • Merge the engine’s report.receipts into the tool result’s receipt.
  • agent_export reads run.report like every other tool.
  • The IGNORED ARGUMENTS text 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 as transformed → center (or supported — that is a separate fix the owner can order).
  • An out-of-range strokeWidth: dropped, instead of a console.warn that production strips.
  • A duplicate relation: transformed with the rule merged-into-existing. A re-registered render hook id: transformed with replaced-previous (or refused; owner’s call, see 1.5).

Exports (R3, AR-7 in passing).

  • Each format declares the options it reads.
  • quickExport reports every option it did not pass on: platform dimensions and GIF scale today.
  • The export receipt carries lastVideoReport for every format, not only MP4/WebM.
  • deterministic:true on H.264 is either honoured 3/3 byte-identical or transformed with not-bit-exact-on-h264 (B31).

Item references (R4, part of AR-4). Every receipt names the resolved target.

  • $N that resolves against a previous batch, or falls back to the literal string, is transformed with the rule positional-ref-previous-batch, or refused before any mutation.
  • Batch $N out of range is refused at preflight, not mid-run.

Cloud (R5).

  • Wrap MCP_TOOL_HANDLERS so cloud-implemented tools get the same receipt as the generic path.
  • Copy applied/dropped into _canvas.receipt.
  • Render jobs carry an export receipt for every format (extends ExportReport).
  • No MCP_TOOLS_REVISION bump 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”)

  1. Unknown keys: declared. A drop goes in the receipt and the call proceeds; strict: true (per call or session) refuses instead.
  2. textAlign:'justify': declared for now. The fallback to center is a transformed entry; justify support is a separate text-layout change, later.
  3. Re-registering a render hook id: refused. The second registration fails by name and the first hook stays.
  4. $N across batches: refused. A $N that 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_expression with ExpressionIR, orbits, morphs_to;
    • generators;
    • registered render hooks (as source);
    • counters, emitters, lifetime;
    • the score;
    • chain branches (#29 §7, PR #193).
  • Expanded at authoring time (the expansion is what is stored):
    • staggerItems writes animationDelay per item and keeps no record of the stagger.
    • Choreograph is compiled by the MCP into keyframes.
    • Letter-collage motion is stored per letter.
    • applyTextEffect writes keys to every glyph, and stores the spec as has_text_effect. It is half a pattern node already.
    • Cloud-side: character_pose/character_animate store many create_item ops 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 a t-readback agree, and keeps it deterministic: no Math.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 is members changed.
  • Members by name or tag need names to resolve everywhere. The minimal part of AR-4 comes with this: create({ name }) already sets data.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.
  • staggerItems and applyTextEffect keep 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

  1. Approve the v1 kinds, or name others from the battery first.
  2. 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.