← Files PicsartARCHIVED FILE
docs/IP-BOUNDARY.md
25 KB · Oct 10, 2026 · 18:02 UTC
# IP boundary — what moves behind the MCP surface, and what must not **Status: proposal, 2026-09-14.** Written from the question *"verify could become a tool the chat calls — what else can move, without harming the plugin."* Nothing below is built. One decision (flag 1) needs a yes before step 1. ## The reframe: verify alone protects nothing `picsart_prompt_verify` on its own is an **enforcement** win, not an IP one. It checks a prompt against rules; those rules are `PROMPT-BLOCKS.md` and the four `PRESETS-*.md` files, and the model still has to read them to *compose* the prompt in the first place. Ship verify server-side and leave the recipes in `skills/` and every canonical string is still sitting in the installed plugin, greppable. The unit that actually moves is **compile + verify together**: the model sends structured shot intent — which `picsart_shotlist_board` already captures as pills (`size`, `arrangement`, `height`, `move`, `lens`, `light`, `palette`, `sec`, plus `action`/`line`/`assets`) — and the server returns the compiled prompt. The model never holds the fragment tables, so there is nothing to leak and nothing to paraphrase. That reordering has a second effect worth more than the first: **correctness at compile time beats correctness at check time.** A compiler cannot emit a paraphrase of the no-music line, cannot put the shutter-angle line on a still, cannot drop the head-count exclusives. Most of `check_dispatch_intent.py`'s mechanical half becomes unreachable-by-construction rather than caught-after-the-fact. ## Three zones, not two — the finding that decides most of this "Backend" is not one thing. Measured in both repos today: | Zone | What is in it | Who can read it | Evidence | |---|---|---|---| | **A · shipped as source** | `plugins/picsart-film/skills/**` — 11,059 lines of `.md` + the Python checker | anyone who installs the plugin; it is a git clone | `find skills -type f \| xargs wc -l` | | **B · shipped as compiled client code** | the widget bundles the MCP server serves as resources | anyone who opens the widget's JS; strings survive minification intact | the compiled fragment `film noir chiaroscuro, venetian-blind hard shadows, wet streets, night-for-night blacks` — a `frag` value, not a UI blurb — greps verbatim out of the 739 KB `server/widgets/dist/film-setup.html` | | **C · server-side handler** | `server/src/mcp/tools/*.ts` — runs in-cluster, never shipped | only through the tool's inputs and outputs | — | **Only C is a boundary.** This matters immediately, because the look, palette and camera preset recipes *already live in the server repo* — `server/widgets/src/film-setup/presets.ts`, 1,226 lines, with `compileStylePrefix` — and they are in **zone B**. They are as readable as the markdown; they just take one more step. Moving a rule from A to B is not a move. Any plan that says "put it in the widget" has done nothing. ## The sorting criterion One sentence does all the sorting: > **A file can move if the model only needs to CALL something that applies it — > not READ it in order to behave.** Anything the model reads to *talk to the user*, *decide what to do next*, or *write prose with them* stays. Anything it reads to *look something up and paste it* moves. ## Tier 1 — moves (the compile + verify unit) The lookup-and-paste corpus. Also the most copyable part: a competitor who lifted these files would have the pipeline's tuned output without the pipeline. | File | Lines | Why it moves | |---|---|---| | `film-scenes/references/PRESETS-CAMERA.md` | 758 | fragment-with-slots tables — 50 moves, sizes, FOV anchors, each compiling to a verbatim clause. Pure lookup. | | `film-scenes/references/PRESETS-LIGHTING.md` | 230 | same shape, 17 setups | | `film-scenes/references/PRESETS-ACTING.md` | 93 | same shape, the emotion wheel | | `film/references/PRESETS-LOOKS.md` | 636 | genre/tone/palette recipes + the assembly recipe. **Already duplicated into zone B** (`presets.ts`) — so this row is **only a move if paired with step 2b**; otherwise the widget keeps serving the same fragments to the browser. | | `film-scenes/references/PROMPT-BLOCKS.md` | ~450 of 697 | the house lines, the tagging header, the two-part template. The rationale prose (why the 15-block template died, the measured descriptor test) is documentation, not a rule — it stays or goes to an internal doc. | | `film-scenes/references/PROMPT-VERIFY.md` | 253 | the procedure; becomes the tool's own behaviour | | `film-scenes/scripts/check_dispatch_intent.py` | 575 | the deterministic engine — a port, mostly written | | `film-scenes/scripts/test_dispatch_intent.py` | 289 | moves with it, inverted (below) | | `film-assets/references/ASSET-SHEETS.md` | ~350 of 895 | the portrait/profile/sheet prompt shapes and the crop recipe. The judgement half — what the board judges, the photograph-as-asset rules — stays. | ≈ **3,600 lines**, about a third of the corpus, and the third with the highest ratio of tuned-output-per-line. ## Tier 2 — stays (moving these harms the plugin) | File(s) | Lines | Why it must stay | |---|---|---| | `film/SKILL.md` | 893 | triage, pace, how you talk to the user, the ten rules, gates. The model reads this to *behave*; a tool cannot supply it. | | `film-development/DRAMATURGY.md`, `SCREENWRITING.md`, `VISUAL-BIBLE.md`, `BREAKDOWN.md` | 392 | the model writes the story **with** the user, revision by revision. A round trip per revision kills the one stage that is a conversation. | | `film/references/WIDGET-FEEDBACK.md`, `REVIEW-FEEDBACK.md` | 891 | the protocol for reading what a board sent back. Needed *before* any tool call. | | `film/references/PIPELINE.md` | 459 | stage map and gates — orchestration | | `film-edit/SKILL.md` + `ASSEMBLY.md`, `JOIN-REPAIR.md` | 693 | diagnosis of a bad join is judgement against evidence the model is looking at | | `film-finishing/**`, `film-assets/SKILL.md`, `film-development/SKILL.md`, `film-scenes/SKILL.md` | ~2,600 | the conveyors: what happens in what order, what to ask, when to stop | | every **Traps** section | — | these are what stop the model doing the wrong thing; they have to be in context *before* it acts | Also true, and worth saying plainly: **these are not the copyable part.** An orchestration file is worth little without the recipes it orchestrates, which is exactly why the split falls where it does. ## Tier 3 — split - **`SEAMS.md` (311).** The model needs the 30°/size-step reasoning while *planning* joins (stays); the *check* that a cut list obeys it is mechanical (moves into verify). - **`ASSET-EFFICIENCY.md` (99).** The asset prefix definition moves; the budget reasoning stays. ## The tools **1 · `picsart_film_compile_prompt`** — free, read-only, no widget. Input: the film's locked setup (`stylePrefix`, medium, ratio, model) + the scene + the run's shots in `picsart_shotlist_board`'s **existing** `ShotInputSchema` shape + the reference manifest. Output: the compiled prompt string, the reference order, and the verdict table. The input schema is largely already written — `shotlist-board.tool.ts` `ShotValuesInputSchema` is it. The board is already the thing that collects this, in the vocabulary (`sodium`, `overhead fall`, `54°`) the recipes are keyed by. **The server holds the vocabulary today and the plugin holds what each word compiles to; this tool moves the second half to join the first.** **2 · `picsart_prompt_verify`** — free, read-only, no widget. Still needed after 1, for two things a compiler cannot cover: a prompt a human wrote or hand-edited, and **pass 5** (does anything escalate, was an intensity chosen, mask/leak, arrival for a latecomer, size vs arrangement). Deterministic half may block; the judgement half advises only — per the existing decision, a model that passes a prompt once and fails it next time must never stand between the user and a render. **3 · `picsart_film_presets` — do NOT build.** A tool that returns the fragment tables to the model is zone B with extra steps. If a surface needs a fragment, it needs the compiler, not the table. ## How the prompt gets built — and why it needs no second AI The obvious worry is that the server would have to *understand* the film — characters, locations, script — and therefore need its own assistant. It does not. **The prompt is a form, and the two halves of it come from two different places, neither of which is a model thinking about the story.** The chat assistant already holds the creative context and has already written the creative text. What it lacks is the house form. The server holds the house form and needs none of the context. So the call carries the first and the server supplies the second. Mapped over the actual shape (`PROMPT-BLOCKS.md`, Parts 1 and 2): | Part of the prompt | Who supplies it | What that takes | |---|---|---| | visual tone (`stylePrefix`) | server | compile from the console's preset ids — the same `compileStylePrefix` that exists today | | the scene, two sentences | chat, in the call | already written before dispatch | | `EXACT <N> CHARACTERS — NO DUPLICATES.` | server | N is the **union of every shot's visible list**, so a latecomer is counted by construction — the recorded failure (counting only the people in shot 1) stops being possible | | one exclusive line per person | server | paste each asset's saved tell — decided once at asset time, not per prompt | | tagging header + per-picture job lines | server | template over the reference manifest | | appearance-only line, no-music line, no-subtitles + skin/shutter line | server | verbatim constants | | geography line | chat, in the call | one line per scene, already written | | camera line (or its deliberate omission) | server | the film default, plus the rule that drops it when every block has a fragment | | cut list | server | arithmetic over the shots' `sec` | | `[X]-[Y]s` per block | server | arithmetic | | shot type · angle · movement | server | preset lookup on `size` / `height` / `move`, slots filled with the subject's name | | who is visible, action, body language | chat, in the call | already written | | emotion | chat, in the call | picked off the wheel — *"acting is the chat's job"* | | the spoken line | chat, in the call | the approved script | | how it is said | server | the character's frozen `voice` text, pasted byte for byte | | `The subject stops talking, silence in the room.` | server | verbatim constant | Roughly half the prompt is lookup, counting, arithmetic and verbatim paste — **a mail merge, not a judgement.** The other half is text the chat has already authored with the user and simply passes through. Nothing in the middle requires a model to reason about the film. This is also why the split does not cost quality: the half that moves is the half a model was never adding value to, and the half that stays is the half only a model in the conversation can do. ### How the server knows the cast and the locations It is told, per call. Two options, and the first is the right first step: **Stateless (build this).** The call carries the film rows it needs: the cast tags with their view URLs, their `voice` text and their tell; the scene's location; the run's shots. This is not new plumbing — `film.json.assets[]` already holds exactly these fields in exactly this shape, and every film tool on the surface today (`picsart_film_setup`, `picsart_shotlist_board`, `picsart_asset_review`) already works this way: state in, state back, nothing remembered. The compile tool is the same pattern. **Stateful (later, if the payloads get heavy).** The server keeps the film under a `filmId` and the call passes the id plus the shots. Smaller calls, one more thing to own, and a migration. Not needed to ship. ### Where a model IS genuinely needed — and it still needs no chat context Only **pass 5**, the judgement half of verify: does anything escalate, was an intensity chosen or did every face default to the top of the wheel, is that an emotion or the name of one, does a latecomer get a real arrival, is the shot size compatible with its arrangement. That pass reads **the finished prompt and the rules it must satisfy** — both of which are in front of it. It does not need the conversation, the user's earlier corrections, or anything else. It is a reviewer looking at one page, not a collaborator who has to have been present. Which is the same reason it advises and never blocks (flag 5): a reviewer whose answer can change between two identical readings must not stand between the user and a render. ## Tested against a real prompt (2026-09-14) A run prompt generated in a live session — 7 shots, 23 s, three characters — was taken apart against the rule files to answer *could the server have produced this*. It is the only test that matters, and it mostly passes. ### What compiles cleanly — verified byte-for-byte - **The STYLE line.** Its genre fragment, `tense thriller staging, negative space, off-centre framing, withheld information`, is **byte-identical to `server/widgets/src/film-setup/presets.ts:164`**. The whole line is already machine-compiled from preset ids today — it is not something anybody writes. - **Every camera fragment.** `camera locked off on sticks, no movement of any kind for the whole shot` and `slow dolly push-in toward <subject>, one steady speed, ending settled at a tighter framing of <subject>` are verbatim `PRESETS-CAMERA.md` rows with the slot filled. - **Every emotion fragment.** `anger banked: …`, `open fear: …`, `fury: …`, `hope kept private: …` — verbatim `PRESETS-ACTING.md` wheel cells. - **The voice text, pasted five times, identical every time** — ~1,000 characters of byte-exact repetition across three Jake blocks and two Marcus blocks. Exactly what a paste does perfectly and a hand does not. - **The arithmetic.** `4+5+3+3+2+3+3 = 23`, cuts at `4, 9, 12, 15, 17, 20`, 7 shots — all derivable, all correct. - **The camera line is correctly ABSENT**, because every block carries its own fragment (the 2026-09-13 rule). A conditional rule like that is precisely what gets forgotten by hand and never by a compiler. Measured: ≈4,100 characters of a ≈9,000-character prompt are server-ownable — a bit under half, and the half with no judgement in it. ### The three gaps — lines in the prompt that exist in NO rule file Each was invented by the assistant during the session. `grep` across all of `skills/` returns nothing for any of them: 1. `NO weapons of any kind anywhere in this video. Nobody carries, holds, draws or points anything. Every hand in every shot is empty and bare.` — a film-level content constraint. 2. `No speech in this shot. Room tone and two sets of breathing.` — a form for silent shots that the templates never defined. 3. `All three are in the room from the first frame; nobody enters, nobody leaves, nobody is added.` — the *no-latecomer* counterpart of the documented latecomer clause. **And it is not derivable**: Marcus is missing from three shots' visible lists yet never leaves the room. Off-camera and gone look identical from the shot data. **Consequence for the design — and it is the load-bearing rule of the whole plan:** > ### The chat may ADD anything, anywhere. It may not REPLACE a house line with its own words. > > Adding is the chat's job and must never be constrained. Paraphrasing a > canonical string is the one defect this machinery exists to catch. Those are > different acts and only the second is ever refused. This is not a concession made to fit the three gaps — **it is already how the checker behaves.** `check_dispatch_intent.py:137` tests house lines with `flatten(line) in flat`: a *substring* test. It asks whether the required line is present and whether a video-only line is absent from a still. It never asks whether anything else is there. The only place it touches the author's own prose is the banned-adjective gate, which takes a `fragments` argument specifically so pasted house lines are exempt from it. The request schema even carries an open `extra` dict already (`:446`), merged over the known params with a conflict check. So the compiler is **a source of correct parts, never a cage**, and the flow is a loop rather than a funnel: 1. **compile** — the server returns a correct skeleton: house lines exact, fragments pasted, counts and times computed. 2. **the chat owns the string it gets back.** It adds, rewrites its own sentences, inserts a constraint nobody anticipated, restructures a shot block. No permission needed, no slot required. 3. **verify** — reads the final string and asks only: are the house lines still there, byte-identical, and is nothing video-only in a still. **It must never flag text for being unrecognised.** 4. **dispatch.** Named slots are then a convenience, not the mechanism — they exist so a common addition lands in the right *position* without the chat splicing strings: - `filmConstraints[]` → after the tells (gap 1) - `castPresence` → the clause after the head count (gap 3) - per-shot `audio` → replaces the dialogue line on a silent shot (gap 2) Anything with no slot goes in by editing the returned prompt. A fixed slot list could not hold an open authoring surface anyway — the same lesson the server repo already learned about denylists in the media mirror (`FROZEN_MIRROR_SURFACE`). **This does not weaken the IP boundary.** The chat edits the *output*, never the *source*: one compiled prompt reveals no more of the preset tables than it does today. What it protects is the thing that actually makes films work — an assistant that can invent the line the rules never anticipated. Two second-order points fall out of this, and both are good news: - **The invented lines are promotion candidates.** The weapons line reads as reusable; if it is, it belongs in the rule corpus (server side, after the move) rather than being re-invented per film. - **It is evidence for tier 2.** The assistant noticing mid-film that it needed a weapons ban is exactly the judgement that must stay in the conversation. A compiler would never have invented it. ## Flags — constraints this implies that were not stated 1. **This reverses yesterday's decision, and that needs an explicit yes.** `ASK-PROMPT-VERIFY.md` (2026-09-13) chose *"the rules travel with the call"* precisely so there would be no second copy and **no drift by construction**. That design is incompatible with hiding the rules: rules that travel with the call are rules the caller holds. The new drift answer is the opposite one — **the server is the only copy, and the plugin holds zero canonical strings.** The existing pin in `test_dispatch_intent.py` (every canonical string must appear verbatim in `PROMPT-BLOCKS.md`) inverts into a plugin-side gate: those strings must appear **nowhere** under `skills/`. 2. **ChatGPT gains, it does not trade.** The deterministic checker only runs where the host has a shell. On the ChatGPT Apps surface there is none, so today that surface has the step written down and no way to execute it. A server-side checker is the first time that host gets it at all. Strongest single argument for the move — lead with it. *(Also: `dist/chatgpt-production/` is stale — built 2026-09-09, predating the checker. `build_chatgpt_plugin.py:100` does ship `scripts/*.py`.)* 3. **Residual exposure is real — say "not shipped as source", never "protected".** The compiled prompt is returned to the model and shown to the user. Anyone can call the tool repeatedly with varied pills and reconstruct the fragment tables. This is ordinary API exposure: it raises the cost of copying from *clone the repo* to *run a harvesting campaign against a metered, authenticated endpoint*. That is a real gain, and it is not secrecy. 3b. **A tool schema is wire-visible — zone B in a different coat.** Anything in a `@Tool` arg description ships in `tools/list` to every client. `shotlist-board.tool.ts` already enumerates all 17 light ids there, which is fine: that is **vocabulary**. The trap is the compile tool's `move` / `light` enums tempting someone to describe what each one *compiles to* — which re-ships the fragment table through the contract. **Rule: enum descriptions carry labels only, never fragments.** Enforce it the way the server already enforces the upstream-vocabulary scrub — `__contract__/upstream-vocabulary-tests.ts` scans the served contract, every wire golden, `tools.json` and `PLUGGABLE_SERVER_INSTRUCTIONS` for forbidden strings; extend it to assert tier-1 canonical strings appear in none of them. That is the server-side twin of flag 1's inverted plugin pin, and it is mostly written. 4. **`picsart_generate` stays untouched** (rejected 2026-09-13). The new tools return a prompt and a verdict; the model still makes the generate call. Ask 14 in `PLATFORM-ASKS.md` is not reopened here. 5. **The judgement pass is billable until proven otherwise.** `McpFlowImpl.executeTask` forces `chargeCredits: true` with no opt-out (server `CLAUDE.md`), so a server-side Claude call on that path charges by default. Plan for `BILLABLE_TOOLS`, the manual sweep, and **once per dispatch, never per draft iteration** — which is how `ASK-PROMPT-VERIFY.md`'s open question 1 resolves. Probe `picsart.com/credit-usage` on a text-mode call against stage before classifying; only a confirmed non-charging route reopens it. 6. **Per-tool server gates** (`ASK-PROMPT-VERIFY.md` §4): `mcp:contract:update`, a `tool-risk.ts` classification, a hand-authored `tools.json` entry, `mcp:descriptions:update`, `MCP_SURFACE_VERSION`. Plus: the film suite is registered only on `film-ux-polish`, not `main` — this lands on the branch and inherits the launch-freeze question. 7. **`docs/` ships too.** `build_chatgpt_plugin.py:106` copies `plugins/picsart-film/docs` wholesale into the ChatGPT package, so `PLATFORM-ASKS.md`, `SKILLS-AUDIT-*.md` and this file reach users of that surface. Whatever the boundary is, the docs directory is currently inside it. Fix is one `ignore_patterns` change; decide it with the rest. ## The argument nobody asked for: context budget `skills/` is 11,059 lines. Progressive disclosure keeps most of it out of any one turn, but the shooting stage pulls `film-scenes/SKILL.md` (1,664), `PROMPT-BLOCKS.md` (697), three preset files (1,081) and `ASSET-SHEETS.md` (895) to write one prompt. Of those 4,337 lines tier 1 accounts for 1,881 — **43%**, gone from the context the model needs *at the most crowded moment in the pipeline*: the one where it is also holding the shot list, the asset manifest and the user's last three corrections. That is a quality argument for the move, independent of IP. ## What it costs — the honest ledger - **A round trip per run.** Compile once per run (not per shot); a run is already the unit of generation, so this is one call where there is already one generate call. - **The model can no longer improvise a fragment.** Intended: paraphrase is named as a failure in `PROMPT-VERIFY.md`. But a genuinely novel shot the preset tables have no id for now needs an escape hatch — a `custom` passthrough, the way `film-setup`'s custom genre already works. - **A rule change becomes a server deploy**, not a markdown edit. Today a wrong fragment is a one-line commit to the plugin; afterwards it is a tagged release. This is the real cost, and it argues for moving tier 1 *whole* rather than in pieces — a half-moved rule set means two deploy cadences for one rule. - **Offline/degraded behaviour.** `film/SKILL.md` has a *When a tool or endpoint is down* section and the skills advertise tier-1 fallbacks. After the move there is no fallback for prompt composition: no server, no prompt. Decide whether that is acceptable or whether a minimal local shape survives. ## Build order 0. **Confirm flag 1** — the drift decision inverts. Everything else waits on it. 1. **Port the deterministic engine as `picsart_prompt_verify`** — free, read-only, returns the verdict table. Mostly a port of tested code. 2. **Build `picsart_film_compile_prompt`** on the shotlist board's existing input schema, and **delete tier 1's canonical strings from `skills/`** in the same change. Moving the rules without deleting the copy is the drift problem created on day one. 2b. **Strip the fragments from the widget bundle.** `film-setup`'s preset items keep `id` / `name` / `blurb` / `bg` / thumbnail — UI copy, not IP — and lose `frag`; the console calls the compile tool for the prefix it displays. The precedent is already in the repo: `picsart_drive` is `openai/widgetAccessible` so the Drive browser navigates and mutates via `host.callTool`. Without this, step 2 moves PRESETS-LOOKS out of zone A and leaves an identical copy in zone B. 3. **Add the judgement pass** to verify (after flag 5's probe answers). 4. **Then** revisit blocking `picsart_generate`, if still wanted. ## Open questions 1. Is the boundary *the shipped plugin* or *the repo*? The repo is on GitLab behind Picsart auth; the plugin is a public marketplace install. Flag 7's answer depends on this. 2. Which surfaces must keep working with no server — and does "no prompt without the server" fail the ChatGPT Apps review, which sees tools go down?
SHA-256: 8ed5b76d495c364d7db462bd97730109506f4c6c57521c7a2d4df50efba15e3d