Something other people use
A request like "make a survey for my talk", "I need a sign-up sheet", "let people book a slot", "a form I can send a link to" is asking for a SHARED APP — a thing that lives on the web, keeps its answers in one place, and can be handed to people who do not have this repository or this machine.
Do not offer a printable page, a Google Form, or a stand-alone HTML form as the answer. They are what this looked like before there was anywhere to keep the answers, and each of them leaves the user to solve the actual problem — where the responses go — by themselves. Offer them only if the user turns this down.
What a shared app is
- One repository is one app. The folder this session is open in becomes the app; its
declaration is
app.jsonat the root. - The definition is committed; the answers are not. Schemas and views are files in the repository. Records live in the app's cloud store, so everyone sees the same rows.
- Who may do what is a list of email addresses in
app.json. Inviting somebody is adding a line and publishing — they need no account here and no repository.
Start from a template when one fits
Nine shapes are written out in full — declaration, schemas, and the reasoning behind each key:
- templates/salon.md — a request that a NAMED PERSON approves, and only
their own (a salon's bookings, interviews, repairs, review assignments). This is what
assigneeis for. - templates/gym.md — first come, first served, with a waiting list and
a per-class opening time (a gym class, a workshop, a slot booking). This is what
stampFieldandwindow.fromFieldare for, and it explains why the capacity lives in the VIEW and not in the rules. - templates/survey.md — collecting answers, with nothing to run out
of (a survey, a quiz, an application form, a sign-up with no cap). The shortest declaration of the
nine, and the shape most often written with a public page and nothing else — so this one is built
around its
memberpage, which is where the answers are read. It also spells out the three-way trade above, and what a tally may and may not claim about values a respondent typed. - templates/meeting-room.md — a bookable unit you can LIST IN
ADVANCE, taken on the spot with no approval (a meeting room, a desk, equipment on loan, a parking
space). This is what
idFrom: "field"andmirrorare for, and it is the one that spells out who refills the slots, and what a cancellation does NOT do. - templates/live-poll.md — a page that MOVES while people are looking
at it (a live stream, a lecture, a stand-up quiz). This is what
views[].liveis for, and the only one whose declaration is decided by FAN-OUT: the audience may watch the questions, only the desk may watch the votes, and the shape that would let both is refused by publish. In English, because the strings in its pages are what a stranger reads. - templates/project-board.md — the same work-claiming board with
a ROSTER and an owner's desk: people register a name once (
names,idFrom: "auth.uid"), then take work; the owner adds and removes tasks and frees an abandoned one from/m/. This is whatwriterDeleteis for, and the one that spells outview.mine()'s three states — "you have not registered" and "nobody looked" are different answers, and treating them alike shows the registration form to somebody who has already registered. It also shows the owner-only form: apublic.submitdeclaration whose window is CLOSED, which is how a page gets a create form that only a writer may send. - templates/append-feed.md — a record a CLOSED GROUP writes to and
nobody rewrites: rows are only ever added, and only their author may remove one (a shift handover
note, an incident timeline, a team log, a class's question board, a chat room). This is what
views[].limitis for — the one shape whose collection grows with the app's AGE rather than with the thing being booked — and the one wherewriterDeleteis deliberately ABSENT, which isproject-board.mdturned over: an owner pressing delete on somebody else's row is refused by Firestore. It is also the only sample showingpublic.enabled: falsebeside a populatedpublic.submit, which is the pair the naming makes look contradictory. - templates/ai-council.md — a discussion the participants are AI
AGENTS rather than people, held in public, which a human host can END (a debate, a design review
by several models, a red-team panel). This is what
agents[]is for — the app publishes the JOB beside the pages, so an agent at another MulmoTerminal takes a seat from the slug alone, with no copy of your repository. It is also the only sample that has to bind its own OWNER: every agent here signs in as the owner, so the host's close is enforced byrefIn, atransitionsmap with no exit, andsealedtogether — any one alone is walked around in two writes. Read it for what a declaration can and cannot hold when the writer is an agent you handed your sign-in to. - templates/magazine.md — several writers publishing things to READ,
each at its own URL, each signing their own and editing nobody else's (a team blog, a newsletter's
back issues, a research log, release notes, a review column). This is what
views[].articleis for — the platform draws the article page, so the app declares which fields are the title, the body and the byline, andidFrom: "slug"makes the writer's chosen name the document id and freezes it. It is the only sample that states aprotocolof its own, the only one whoselimitis a COST — publish works it out in bytes and refuses the declaration when it is too large — and the one that explains why the owner has to holdparticipanton their own collection —audienceforcessubmitOnly,submitOnlycloses the writer branch, and an owner who skips it cannot publish at all. Read it before any app where a record is something a stranger is meant to sit and read.
Read the matching one before writing app.json by hand. All nine are checked against the real
publish gate by this repository's tests, so what they show is what publishes — and they spend most
of their length on the traps, which is the part you cannot recover by guessing.
They also show what the page should LOOK like, and that is not decoration. The page is the only
thing a visitor ever sees — nobody reads app.json — so one that arrives as unstyled boxes does
not read as plain, it reads as unfinished, and an unfinished-looking booking form is one people
close. templates/design.md is the rules: a palette derived from one
hue you choose for this app, fluid type, a radius and a shadow that descend with the nesting, and
what to do about the fact that you can load no stylesheet, no webfont and no image. Read it before
you write the pages, and change --hue — every template ships a different one so that copying
a template does not make every app the same colour, and a page left in the template's colours is a
page nobody chose.
The path
Say what you are doing in the user's words ("作っています", "みんなが見えるようにしました"). The
words below are for you, not for them: an author does not need to know what a cid is.
1. Start the app
manageSharedApp with action: "init", and name (and slug, if you have one worth wanting).
Do not compose app.json yourself. The declaration names its owner by EMAIL and it has to be
the address this machine is SIGNED IN with — you cannot read that, and the address the user tells
you is the one that fails at publish. init writes it, generates the aid, and refuses if the
repository already declares an app.
init also TAKES the aid on the server before it writes the file, so it needs a connected
session and reports a refusal instead of leaving a half-started app. That is not bookkeeping: the
id lives on a shelf shared by everyone using this deployment, app.json is meant to be committed,
and an id that is written down but not yet taken can be taken by whoever reads the file first — and
an app id can never be freed. If the reservation is refused, nothing was written and init can
just be run again.
slug is the name in the URL people will be given. Take it from what the thing IS
(aug-talk-survey), lowercase with hyphens. It is a wish: if it is taken, a number is appended and
written back. init RESERVES it along with the app id, so the address is fixed from the start —
and the reservation can never be freed, which is why it follows a name the user wanted rather than
one you invent. The name resolves for the app's own roster immediately (/m/{slug}) and for
everybody when you publish.
The file is an ordinary committed declaration afterwards — you may read it, and the user may edit
it in a pull request. What you should not do is REWRITE it: invite changes one roster entry, and
check tells you whether what is there would publish.
The repository is a CLONE of somebody else's app
manageSharedApp with action: "fork" — not init, which refuses here, and above all not by
editing app.json yourself.
A cloned repository already carries a declaration, and the schemas beside it are exactly what the
user cloned it FOR. fork mints a new aid, makes the signed-in address the only member, and
carries collections and public over unchanged. It does not touch .claude/skills/.
The signals are a user saying this is a clone, someone else's address in members, or init
telling you an app is already declared. Ask for a name and a slug before you run it — the
cloned app's URL name is deliberately NOT carried, because kept it would be honoured as a wish and
come back as their-name-2, which is a name nobody chose.
What the user must be told, in their words: the app they cloned is untouched, and its answers do not come across. They are getting the same form, empty. And the people on the old roster are not on theirs.
fork refuses when the signed-in address already owns the app — that is not a clone, and forking
it would abandon the existing app and every record in it.
2. Write the collection
One collection per kind of record — a survey has one (responses), a booking app might have two
(bookings, services).
A NEW collection is created by writing the files: SKILL.md and schema.json under
.claude/skills/<slug>/. putSchema is EDIT-ONLY and refuses a collection that does not exist
yet ("unknown collection … create it by writing SKILL.md + schema.json"), so do not try to create
one with it. Use it afterwards, to CHANGE a schema.
Read the shape first: manageCollection with action: "schemaDocs", and
topic: "Shared storage (firestore)" for this part specifically. The shape is not what a
reasonable person guesses — fields is an OBJECT keyed by field name (not a list), primaryKey
and icon are required, and the key for a field's human name is label. A schema in the shape
you would design does not parse, and a collection whose schema fails validation is skipped
silently: nothing errors, it simply never appears.
That topic covers the STORAGE key and not the field types, and the two are asked for separately:
topic: "Field types" is where a datetime's exact format lives, and it is the one a seeded
collection gets wrong (step 2b).
The one thing that differs from an ordinary collection:
{ "storage": { "type": "firestore" } }
That is what makes the records shared. Declare no dataPath beside it — exactly one of the two.
The app already has its aid — init wrote it in step 1 — so a shared collection you write
correctly is discovered straight away. If getSchema says "unknown collection" after you have
written the files, that is the schema FAILING VALIDATION, not something publishing will fix: read it
back against schemaDocs (primaryKey naming a field flagged primary: true, icon present,
exactly one of dataPath / dataSource / storage). Publishing past it produces an app with the
collection missing and no error anywhere.
Everything in the folder is shared or nothing is. Do not mix a shared collection and a local one in an app's repository.
2b. Load the inventory, if the app needs rows before anybody arrives
Some apps have nothing to show until rows exist: bookable slots, a timetable, a menu of services.
Nothing generates them — the platform runs no code of its own — so they are written with
manageCollection putItems before the app opens, and topped up later (the meeting room's
枠の補充 covers the weekly refill task).
Prove ONE batch before you generate thousands. Write a single day, read it back with
getItems, then run check (step 4b). putItems REFUSES a row missing a required field or
carrying an unknown enum value (and, under mode: "create", an id that already exists) — that is
what it refuses, and it is not everything it checks. The SHAPE of a typed value (a real date, a
numeric number, a datetime's exact format) is written and reported: the answer carries a
lint block beside written, and the same rows are refused at publish. So an empty rejected
is not proof — 720 accepted rows are not 720 valid rows — and one day first is one round trip
against one regeneration per batch.
That proof needs a session. check answers offline, and offline it does NOT read the records —
it says so in as many words ("the live records were NOT scanned"). A check that has not scanned
them proves nothing about the batch you just wrote, so connect first, and read what it says about the
records. Exactly one answer is a proof: the scan RAN, over every shared collection in this
repository, and found nothing.
Everything else is repaired first, and the rest are not degrees of that one — they are different
repairs, and more than one can be reported at once (a collection that could not be read does not
stop the others being scanned):
- rows that do not fit — named, and a MIGRATION.
confirmat publish is the decision to break them for everybody, not a way past this. - UNKNOWN — a collection could not be READ, so nothing at all is known about the rows behind it. That is access, not data.
- not scanned — the line says which: no session, or an
app.jsonthat does not parse.
datetime is a wall clock, not an instant. YYYY-MM-DDTHH:MM, seconds optional, no timezone
suffix. new Date(...).toISOString() is the reflex and it is wrong twice: the Z is refused at
publish, and the time SHIFTS into whatever timezone this machine is in — a Tokyo court's 08:00
becomes 15:00Z when the script runs in Seattle, and 16:00Z for the same 08:00 in winter, because
the offset moves too. Had the format been accepted, the app would have published with every row
seven hours out — eight, on the dates the other side of the change. Build the string from its parts
(`${dateKey}T${hh}:00`). A stampField's …Z (step "limited number of places") is the one
datetime shaped that way (nine fractional digits), and the rules write it — no script does.
putItems flags the wrong shape in lint as it writes, so the reflex is caught on the first batch
rather than at publish — if you read the answer.
Generate with a deterministic script, and do not write the rows out yourself. Dates, month ends
and daylight saving are what an LLM gets wrong, and a few hundred inline rows are tens of KB emitted
a token at a time. Have the script write a bare array of records to a JSON file under the workspace
and pass its absolute path as putItems' itemsFile — 1000 rows and 8 MiB per call, and an
over-limit call writes nothing at all.
Pass mode: "create". The default REPLACES a whole record, so a re-run — a retry, a refill that
overlaps what is already there — silently overwrites fields nothing regenerated, and reading the ids
first does not save you: another run or a hand edit can create the same id in the gap between the
read and the write. create has the host refuse a colliding row instead.
And then read rejected AND lint. They answer different questions, and only one of them is
about rows that failed to land. lint appears beside written when a row WAS written and its
values are the wrong shape — a datetime that is an instant, a date that is not a real day, a
number holding text — with total (every flagged row) and rows (the first ten, as
{ id, problem }). Those rows are in the collection and publish will refuse them, so a lint block
is the generator to fix and the batch to rewrite, now rather than after the other 719. No lint
key at all is the clean answer, and total — not the length of rows — is how many there are.
Rewriting them is the one place mode: "create" is the wrong mode: the rows exist, so create
would refuse every one of them. Fix the generator, regenerate the SAME ids, and send them with the
default upsert — the script produces the whole record, so replacing it whole is exactly right, and
it is only safe here because these ids are the ones you just wrote in this same batch.
rejected is the other half. It is not a count and not only about collisions: putItems returns
{ written, rejected } with one { id, problem } per refused row, and the problem is as likely
to be a missing required field or an unknown enum value as an id that already existed. So go
through them: every problem that is not "already exists" is a row that was NOT written, and it
needs fixing and re-sending — just that row. A refusal of the whole CALL (over the row or byte
limit) is a different thing and does not arrive as rejected at all: no { written, rejected }
comes back, nothing was written, and the fix is to split the file rather than to re-send rows.
And "already exists" says exactly that much: the id was there when the write ran. It does not say
who put it there — an earlier attempt of this same refill, another run, a hand edit — and it does
not say the stored row is the row you just generated. create cannot correct it either. If that
matters — a regeneration that changes what a slot should say — read those ids back with getItems
and compare before calling them done.
2c. Decide the ENTRANCES. An app with one page is usually unfinished
Three entrances exist, and only the ones written into views are real:
audience |
address | who opens it |
|---|---|---|
public |
/a/{slug} |
anybody the app admits |
member |
/m/{slug} |
anybody holding a role in members |
participant |
/p/{slug} |
anybody LISTED in members, seeing their own row |
An app that collects records gets a member page, and you do not ask first. Without one, the
only way to read what was collected is the collection pane on the author's own machine — so the
answers exist but nobody can reach them from a phone, and the author finds this out after handing
the link around. It is not a feature the author chose to skip; it is one nobody mentioned. Write
it, and say in their words what it is ("集まった回答はここで見られます").
This is the step that gets missed. The reason is worth knowing: an app built from a template
inherits that template's pages, while a form-shaped app is written from scratch — so the apps with
no member page were the ones nobody had a sample for. Every template now shows one, and
templates/survey.md is the one for a form.
Whether an answerer can see their own answer back is a real question, and it is not "do you want a participant page". Ask whether the people who answer need to see their own answer later, then pick the route from the three below — only the third one costs an invitation, and it is reached for only when a custom public page is also required:
/p/{slug}is readable only by people on the roster. A stranger who answers a public form is not on it, so a participant page renders for nobody unless the author invites them one by one.- A generated public form (an app declaring no
publicview) already shows a visitor their own answer back, roster or not. Writing a custom public page removes that — the custom page replaces the generated form, and the data a public page may be handed is limited topublic.read, which the submitted records can never be in. - So "anyone may answer" + "a public page I wrote myself" + "answerers can see their answer" is
three things, and only two of them are available at once. Drop the middle one and the
generated form serves everybody; drop the first and
/p/{slug}serves a roster. Say so while the shape is still being chosen, not after the page is written.
The rules are not the obstacle here and do not need working around: a submitter may already read
their own row (emailField, or idFrom: "auth.uid"). What is missing in that third case is a page
they are allowed to open. participantRead does not fix it.
2c-2. If the app publishes ARTICLES, the platform draws ONE page and you draw the rest
An article block on the public view says "this collection's records are markdown, and the platform
draws one of them at /a/{slug}/{id}":
{ "id": "public", "audience": "public",
"path": "views/home.html",
"collections": ["articles"],
"article": { "title": "title", "body": "body", "summary": "summary", "byline": "byline" } }
path is still required, and the page it names is the front page. /a/{slug} is yours — the
index, the masthead, the sections, an About. It is handed the same datasets any public page gets, so
the list of articles is a loop over records you write however the publication should look. The
platform draws the article itself because markdown is rendered on the host's own origin without a
sandbox, which makes the render a security boundary rather than a layout.
Link an entry to its article with view.open(cid, id). A published page is
sandbox="allow-scripts" and nothing else, so an <a href> out of it is inert — no top navigation,
no popups. The page names a RECORD and the host builds the address, which is also why a page cannot
send a reader anywhere but into an article of its own app.
card.addEventListener("click", () => { view.open("articles", record.id); });
It answers { opened, reason } and usually does not answer at all, because a navigation that
happened took the document with it. opened: false with reason: "no-navigation" is a host that
does not navigate — the preview pane, where the log line says the page asked — and there is nothing
for the page to do about it. Do not await it and then act.
Four more things this shape settles:
collectioninside thearticleblock, when the view names more than one collection. With one, it is inferred./a/{slug}/{id}carries nothing that says which collection an id is in.summaryis the article's standfirst, under the title. Your index draws its own summaries out of the records, so the two are not the same line.bylineis a string somebody typed, not an identity the rules hold. Never put an address in it — the field is drawn to the whole world.theme.huecolours the article page and nothing else. Your own pages carry their own CSS, and publish refusesthemeon an app that declares noarticleblock.
type: "article" is REFUSED. It used to mean "the platform draws this page", and it took the index
with it — an app that published articles had no public face of its own. If you meet one in an
existing app.json: delete type, keep article, add path.
2d. If an AGENT is going to sit at this app, give it a written job
A page tells a person what the app is for. Nothing told an agent — an LLM at another
MulmoTerminal, holding useSharedApp — what it is supposed to DO here. That job used to live in
whichever terminal's prompt somebody typed it into, which is not the app: it does not travel with
the URL name, it is not reviewed with the declaration, and it is gone on the next machine.
Write it into app.json, beside the pages:
{
"agents": [
{
"id": "desk",
"audience": "member",
"watch": ["bookings"],
"instruction": "pending の予約が来たら、枠が空いていれば承認し、埋まっていれば却下する。予約の削除は、この端末の人に頼まれたときだけ。"
}
]
}
audienceis the same noun asviews[]— it decides which document the instruction is published to, and therefore who reads it. Amemberbrief lands where only role-holders read; apublicone lands on the world-readable document, so never put internal vocabulary in it (when to approve, when to delete).checkrefuses a public brief that names anything outsidepublic.read.- Write the instruction in the user's language. It is read by an LLM, not parsed.
watchnames the collections the job waits on. The sitting agent is the one that subscribes —describenever starts a watch, because a subscription that begins when somebody merely LOOKS bills the app's owner a read per row.- It grants nothing. "Approve everything" against a transition this reader does not hold is refused exactly as before, and the person at the terminal overrides the brief. Publishing a duty is not publishing a permission.
- One brief per audience is the shape to reach for. There is no role filter in it: an
ownerand avieweron the member tier read the same brief, as they read the same pages. - Do not leave the playbook only in the HTML. A page an agent never opens says nothing to it.
Run check after writing one. Then run describe as the audience that will sit here — and
know that the author's own describe is usually the owner's, so it shows the member briefs and not
what a public visitor would see. That is the identity limit of one signed-in account per machine,
not a fault in the app.
3. RUN THE PAGE. Not reading it — running it.
There is no step between writing the app and publishing it. deploy — which wrote a copy only the
roster could see, at /staging/{aid} — is gone: an app EXISTS from init (its id and its URL name
are taken, and its records can be written) and everything else is written by publish. So this
step is the only thing standing between what you wrote and what everybody sees.
A page you have not seen work does not work. Everything a view does that is broken by the sandbox fails the same way: nothing is drawn, nothing throws, and the HTML reads perfectly. You cannot find these by looking at your own code, because the code is not wrong — the frame it runs in is stricter than the one you pictured. The only thing that finds them is pressing the button.
You can press it yourself: manageSharedApp with action: "preview". It loads every page the
declaration names in a real headless browser — the same parent, the same
sandbox="allow-scripts", the same CSP, the same private-port handshake — hands it the app's real
records, and presses each control on a freshly loaded copy of the page. It runs to a budget and
says what it left out — pages it did not run, controls it did not press — so read those counts
rather than reading "ran 6 pages" as "ran the app". What comes back is what
you would otherwise have to be told by somebody looking at a screen: a page still on its loading
state (quoted, in the author's own words), a <form> in the live document, a button that reached
nothing, a submission the declaration refused. Run it after writing or editing any view, and
again before you publish. A page that has never been through it is a page nobody has run.
By default it writes nothing — and that is the mode to use after every edit. It still loads every page, presses every control and reports everything; submissions are simply reported and left unwritten. Reach for this one freely.
confirm: true lets it write, and you must ASK THE USER FIRST. When a press produces a
submission, the run then makes a real record in the real app and removes it in the same breath — so the report can tell you what the
deployed rules say, which is the one answer an author most wants before publishing and the one
no amount of reading the declaration produces. Each line says whether the record went in, why it
was refused if it was, and whether the removal succeeded — a booking left standing occupies a
real slot, so never skip that part when reading the report back.
Only a submission the runtime marked as caused by the click is written. A page can submit from a
timer, from onState, or from a promise settling, and no amount of measuring before and after a
press tells those apart — so the proof comes from the runtime injected into the page, which is in
the same realm as the event and knows whether submit() was called while a real click was being
dispatched. Everything else is reported as withheld and nothing is written for it.
An async click handler that awaits real work is withheld, and this is the one that will confuse
an author. These two are identical in shape and land on opposite sides:
button.onclick = async () => { await Promise.resolve(); view.submit(...) } // written
button.onclick = async () => { await validate(); view.submit(...) } // WITHHELD, if validate yields
The second resumes in a later task, and a later task is not the click however fast it was. So a save that checks something first writes nothing in a headless preview. Say that is the reason — an author told only "nothing was written" will go looking for a bug in a button that works.
An app pinned to @receptron/sharedapp older than 0.9.0 lands in withheld for every
submission — that runtime marks nothing, so the run writes nothing at all. It is not a fault in the
page.
A control that saves from its own change handler — a checkbox, a select — is worse than
withheld: it is never exercised, and the report will not say so. Two separate things:
- the run presses button-like controls only (
button,[role=button],input[type=submit|button]), so a page whose only save control is a toggle produces no press at all — and with no press there is nowithheldline to read; - while preparing the page the run ticks checkboxes and dispatches
changeitself, BEFORE the press window. A page that saves from that handler therefore submits outside any press, and the runtime would not mark it in any case (activation behaviour runs after the click's dispatch has ended).
So for such a page the report can look completely clean while the save path has never run. Say that plainly to the user rather than reading the silence as success, and ask them to exercise the toggle in the Collections pane.
There is also a budget on writes. Over it, a confirmation is declined rather than accepted, and the run says how many — read that count before concluding every control was exercised.
Why the ask, when the record is removed a moment later: the removal is not the safety boundary.
While the record exists it is real — a rule, a function or an integration may act on it, a
notification may already have gone out — and the removal itself can fail (the report has a line for
exactly that, and in a first-come app a record left standing occupies a real slot). So say what it
will do and get a yes, the same as for publish. Run the default read-only preview as often as you
like; ask before the writing one.
So the report proves the page draws, the handshake completes, the records arrive, a press reaches the parent as a submission the declaration accepts, and — for the presses that were written — what the deployed rules said.
It also tries to photograph each page, and gives you the path for every one it managed. Open it when the words leave the layout in doubt; that is the one thing prose cannot carry. A page with no picture says why in its own line — the capture failed, or there was nowhere to write it — and that is a fact about the run rather than about the page.
If no browser can be started it says so, and then the pane below is the whole answer rather than the second half of it.
The Collections pane's preview is the same thing with a person in front of it. It is not a
convenience and it is not a rough approximation. Before it existed, an LLM wrote the page and it went to a public URL without
anybody ever having loaded it once — which is exactly how a sign-up form was published twice with
a Submit button that did nothing at all. So the preview runs the same parent as
/a/{slug} (@receptron/sharedapp/view, the code mulmoserver itself runs), with the same
sandbox="allow-scripts", the same CSP, the same private-port handshake and the same confirmation
dialog. It is deliberately not looser than production: a preview kinder than the real page
would be a machine for producing "it worked on my machine".
Ask the user to open it once the headless run is clean — it is the half you cannot do, because
it puts a person in front of the page and lets them judge how it LOOKS, and because it exercises the
controls the headless run never presses (see the toggle case above). What it does NOT add is another
identity: the pane posts to /api/shared-app/preview/submit, which calls the same
writePreviewSubmission as the headless run, so both write as the author. Neither preview can
tell you what the rules would say to a visitor or a participant — only a real session as that person
does. In
the cell open on this repository: the Collections pane → the Previews switch in its header
→ the page appears, drawn from the working tree, and the page picker beside that switch
(<id> — <audience>) chooses which one. In a directory that declares an app the switch starts ON,
so the pages are usually already there; turning it OFF is what shows the collections underneath
them. Opening it reads only: nothing is written and no URL name is taken.
With that switch OFF there is an Access button beside it, and it answers the question neither
preview can. Both previews run as the AUTHOR, so they show what the author's own session may do;
Access shows, per collection, what a person who never signed in and a person who signed in and was
never invited may read and write. It is a transcription of firestore.rules off the two documents
those rules read — the app document and its public block, projected from the working tree — so it
needs no Firestore session and writes nothing, and it changes the moment you edit app.json.
Read it before every publish of an app whose roster is the point, and say what it shows in your
own words rather than only pointing at it: the row to read out is Signed in, not invited. A
public.submit declaration is not a statement that the app is open — an invite-only app needs one so
its own members' pages can write — and reading those two keys as one is what mulmoterminal#1926 was.
The deployed rules decide; if that panel and the deployment ever disagree, the deployment is right
and the disagreement is a bug worth reporting.
Accepting a submission there DOES write a real record, as the signed-in author, into the live app — the pane says so at the button and lists what it made with an Undo beside it. So it is a real answer in the app's data, not a rehearsal: tell the user that before they press Send it, and offer to remove it afterwards. Reaching the CONFIRMATION is what proves the page works; accepting is only needed when you want to see the record land.
Moving a record is the one thing the pane can prove and the headless run cannot. A page asks for
a transition, an assign or a withdraw, and the pane performs it — as the author, judged
first against that page's own projection and then by the deployed rules. The headless run refuses
all three (read-only), so it can only show that a control is wired; whether pressing it actually
moves the record is a question only the pane answers.
On EVERY page, including the public one. A visitor who submitted a row may move it along
selfTransitions and take it away along selfDelete — both declared inside public.submit[cid] —
with no role and no membership: ownRow in the rules asks that they are signed in and nothing more,
and an anonymous session counts. The public page used to be unable to ask for any of it (the ask was
dropped and the page waited for ever), which is worth knowing when reading an older page that works
around it.
And it moves a REAL record, with no HOST confirmation and no undo. A submission raises the parent's confirmation dialog and lands in the pane's Undo list; a member's move does neither — the live desk raises no dialog either, and a move creates no record to take back. So say what a button will do before the user presses it, and remember that reopening a poll question or approving a booking in the preview is the same event a member would have caused.
That is about the HOST, and it does not excuse the page. withdraw still has to ask, in the
page, for the reason below — it deletes a record and hands the slot to whoever clicks next. What
the pane not confirming means is that pressing the page's own confirm button writes for real; it
does not mean the page may stop drawing one.
Ask them to confirm, in these terms:
- the page draws its data (a grid that stays on "loading…" means
ready()was never called); - pressing the submit button raises the confirmation dialog, with the right values in it (no
dialog = the message never left the frame — a
<form>, or a handler that never ran); - on a member's page, pressing a control actually moves the row — the list redraws with the new
status. If it does not, the log below names the refusal:
illegal-transitionis the declaredtransitionstable,not-permittedis the reader's role,not-writableis a missingstatusField/assigneeField/selfDelete/writerDelete, andnot-in-viewis the page naming a row it was not handed (the preview writes as the OWNER, so it cannot let the rules decide whose row it is); - the error paths say something: an empty required field, an unchosen option;
- cancelling the confirmation leaves the page where it was — a page that shows the thank-you or
the results to somebody who pressed やめる read
submitreturning as sent (see "Which screen the page shows"). This is a press the headless run never makes; - reloading after answering shows the answered screen, not the empty form. Same section, same cause on the other side: it means the page remembered instead of deriving. The headless run loads each page once, so this is the user's to press too.
Do this before publish and again after any change to a page. If the user cannot look right now, say plainly what was and was not checked: a clean headless run means the page draws and the button reaches the parent, and it does not mean the write goes through.
When something in the pane does not work, ask for the log rather than for a description. At the
bottom of the pane is a count and a "Copy what happened" button. It holds the facts that exist
nowhere else on the screen: what the page submitted and what the parent REFUSED (a refusal is
answered on the port, so on screen it is a button that did nothing), what the frame reported about
itself (an uncaught error, a modal the sandbox ignored — alert, confirm and prompt do nothing
there and nothing on the published page), and the deployed rules' own refusal of a write that
was accepted. That last one is the half a headless run can never reach, and it is why "Missing or
insufficient
…(truncated)