Group plans: collect, decide, act once, report
prompts/pkg/llms/group-plan.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
This doc is in view because the app holds a job a group does together: a poll that becomes a date, picks that become a bookable list, items people claim, a plan that has to respect what each person privately needs. The app is where the job lives. Anyone who opens it sees the current state, the one open decision and the result, without reading back through a chat.
Every record kind has a fixed set of writers. People write their own
answer and constraint records, and create the job and set its goal. onChange writes staleness.
The scheduled tick in backend.js writes the plan, every intent and
receipt, and the job's progress. A member writes a proposal as themselves,
and a member other than the proposer writes the approval. Everything in this
doc follows from keeping those writers apart.
The records
All nine kinds live in one database, job, and each carries a type.
job, one record with_id: "job":goal(one sentence),status(collecting,deciding,acting,doneorstopped),waitingOn(who or what, in words),deadline(ISO time or null),quorum(how many answers mean "enough"),checksLeft(the budget for scheduled checks; 2016 is a week of 5-minute ticks) andreceiptIdonce done. A page setsgoal,deadlineandquorum, and may lowerchecksLeftto stop early.status,waitingOnandreceiptIdmove only in the tick. The page's first line reads from it: "Waiting on 2 more picks · closes Friday 6pm".answer, one per person per question,_id: "answer:<question>:<handle>":question,value,authorHandle,at. One row per answer, never an array on a shared record, so two people answering at once never overwrite each other and the planner can count them.constraint, one per person,_id: "constraint:<handle>": what only that person and the plan's code should see ("I have a 6:10 train", "the Saturday set is a must for me"). ItsauthorHandleis the only member who reads it.staleness,_id: "staleness", written only byonChange: a marker that an input moved. It names the changed record, so no member is granted it; the tick reads it in admin mode. Its_revis what the plan was computed from.plan,_id: "plan", written only bybackend.js: the outcome and nothing else ("Meet at the north gate at 4. Leave by 5:40."), plusbasedOn, thestalenessrev it was computed from. It never quotes a constraint. A constraint that shaped it shows as a count or a role ("one person has an early train"), never as who or what.proposal,_id: "proposal:<n>":kind(the action),text(exactly what will happen),audience(the member handles it reaches, sorted),amount(cents, or 0),hash(of text, audience and amount),proposedBy.approval,_id: "approval:<proposalId>":proposalId,hash,approvedBy. Create-only.intent,_id: "intent:<kind>:<key>": an action about to happen once (rsvp,publish,order,invite), itsstatus(claimed,sent,confirmedorunclear) and itsidempotencyKey, which is its_id.receipt,_id: "receipt:<intentId>": what the outside world answered (a payment id, a booking reference, a reply). "Done" means a receipt exists.
Private things stay private because of who can read them
Among the members, only its author reads a constraint, and the plan is computed where
every record is in reach: a scheduled tick. onChange reads only what the
triggering person could read, so it can't see the others' constraints. A
scheduled tick runs in admin mode and sees them all. So onChange marks the
plan stale, and the next tick recomputes it with a plain function and writes
the plan record. No other member is granted the constraint channel, and the group's
assistant reads the app as an ordinary member, so it never reads a constraint
and can't repeat one. The app's owner can still open every record with the
admin toggle, so a person who owns the app should say so beside the form.
access.js
js// Summary: anyone in the group answers and reads the plan; among members, a
// constraint is read only by its author (the tick and the owner's admin toggle
// see all); the plan, intents, receipts and the job's progress are written only
// by the scheduled tick.
export function job(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to take part" };
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
const me = user.userHandle;
switch (doc.type) {
case "answer":
ctx.requireAccess("group");
if (doc.authorHandle !== me || doc._id !== `answer:${doc.question}:${me}`)
throw { forbidden: "you answer for yourself" };
return { channels: ["group"] };
case "constraint":
ctx.requireAccess("group");
if (doc.authorHandle !== me || doc._id !== `constraint:${me}`)
throw { forbidden: "you note your own needs" };
return { channels: [`private:${me}`], grant: { users: { [me]: [`private:${me}`] } } };
case "proposal":
ctx.requireAccess("group");
if (oldDoc) throw { forbidden: "a proposal is replaced by a new one, never edited" };
if (doc.proposedBy !== me) throw { forbidden: "you propose as yourself" };
return { channels: ["group"] };
case "approval":
ctx.requireAccess("group");
if (oldDoc) throw { forbidden: "an approval is given once" }; // write-once
if (doc.approvedBy !== me) throw { forbidden: "you approve as yourself" };
return { channels: ["group"] };
case "staleness":
ctx.requireBackend("onChange");
return { channels: ["machine"] }; // granted to no member: it names which record changed
case "plan":
case "intent":
case "receipt":
ctx.requireBackend("scheduled"); // only the tick plans and acts
return { channels: ["group"] };
case "job": {
ctx.requireAccess("group");
// A page sets the goal and may run the budget down; progress belongs to the tick.
const moved = oldDoc
? doc.status !== oldDoc.status || doc.waitingOn !== oldDoc.waitingOn ||
doc.receiptId !== oldDoc.receiptId || doc.checksLeft > oldDoc.checksLeft
: doc.status !== "collecting" || doc.receiptId != null;
if (moved) ctx.requireBackend("scheduled");
return { channels: ["group"] };
}
default:
throw { forbidden: "unknown record" };
}
}
The group channel's membership comes from the app's membership records, the
way the access docs teach a group. When the app is made for a chat's group,
its members are the chat's linked members.
The backend does the waiting
onChange marks the plan stale, scheduled recomputes it, finishes what was
started and decides whether a person is needed. The tick costs no model
credits. It calls ctx.notify only when someone has to decide something or
the job is done, so most ticks are silent.
The notify goes to the app's owner, and dedupeKey keeps it to one
notification per reason. When a chat's assistant made the app for a group,
the owner is the group's handle, so that notification is the job handing
itself back to the chat's assistant: that notification wakes the assistant in
the chat it was made from, which reads the plan and speaks to the group, so
write the body as one plain line the assistant can act on ("Quorum: Thursday
7pm, 5 of 6 answered"), and pass path when the plan has its own screen.
backend.js
jsexport const config = { scheduled: { interval: "5m" } };
const DB = "job";
const INPUTS = ["answer", "constraint", "approval"];
export async function onChange(event, ctx) {
if (event.dbName !== DB || event.docId.startsWith("seed:")) return;
const t = event.deleted ? event.oldDoc?.type : event.doc?.type; // a withdrawn answer moves the plan too
if (!INPUTS.includes(t)) return;
// seq counts per record, so docId and seq together name this change; two marks in one millisecond still differ.
await ctx.db.put({ type: "staleness", change: `${event.docId}:${event.seq}`, at: Date.now() }, { db: DB, id: "staleness" });
}
export async function scheduled(event, ctx) {
const job = await ctx.db.get("job", { db: DB });
if (!job) return;
// Pending intents settle first, whatever the budget, the job's status or the approval say now.
// Nothing is sent here: a claimed or sent intent is only looked up, and an unclear one asks a person.
for (const intent of await allOfType(ctx, "intent")) {
if (intent.status === "unclear") await notifyOwner(ctx, `unclear:${intent._id}`, "A send may not have gone through. Ask before trying again.");
// finish is safe to repeat, so a tick that stopped between confirm, done and notify completes here.
else if (intent.status === "confirmed" || (await settle(ctx, intent))) return finish(ctx, job, intent);
}
if (job.status === "done" || job.status === "stopped") return;
// The budget: a job that has used its checks stops and says so once.
if ((job.checksLeft ?? 0) <= 0) {
await ctx.db.put({ ...job, status: "stopped", waitingOn: "stopped checking" }, { db: DB, id: "job", ifRev: job._rev });
await notifyOwner(ctx, `stopped:${job._rev}`, "Stopped checking: the budget ran out.");
return;
}
// Read the marker first: a change that lands while we compute leaves a newer rev, so the next tick runs again.
const stale = await ctx.db.get("staleness", { db: DB });
const plan = await ctx.db.get("plan", { db: DB });
if (stale && stale._rev !== plan?.basedOn) {
const answers = await allOfType(ctx, "answer");
const constraints = await allOfType(ctx, "constraint"); // admin mode: every author's
const next = computePlan(job, answers, constraints); // plain code, no model call
await ctx.db.put({ type: "plan", ...next, basedOn: stale._rev, computedAt: Date.now() }, { db: DB, id: "plan" });
if (next.answerCount >= job.quorum) await notifyOwner(ctx, `quorum:${next.version}`, "Enough answers are in.");
if (next.conflict) await notifyOwner(ctx, `conflict:${next.version}`, "Two needs collide.");
}
if (job.deadline && Date.parse(job.deadline) < Date.now()) await notifyOwner(ctx, "deadline", "The deadline passed.");
// Act on an approved proposal, or finish one an earlier tick claimed.
const proposal = await approvedProposal(ctx); // the newest proposal whose approval still holds, or null
if (proposal) {
const result = await actOnce(ctx, proposal.kind, proposal._id, (key) => send(proposal, key));
if (result?.confirmed) return finish(ctx, job, { _id: `intent:${proposal.kind}:${proposal._id}` });
}
await ctx.db.put({ ...job, checksLeft: job.checksLeft - 1 }, { db: DB, id: "job", ifRev: job._rev }).catch((err) => {
if (err.code !== "conflict") throw err; // someone edited the job; the next tick counts again
});
}
// Claim, act, confirm. A create-only claim lets exactly one tick win.
async function actOnce(ctx, kind, key, act) {
const id = `intent:${kind}:${key}`;
let intent = await ctx.db.get(id, { db: DB });
const fresh = !intent;
if (fresh) {
try {
await ctx.db.put({ type: "intent", kind, status: "claimed", idempotencyKey: id }, { db: DB, id, ifRev: null });
} catch (err) {
if (err.code !== "conflict") throw err;
return null; // another tick holds it; the next one picks it up
}
intent = await ctx.db.get(id, { db: DB });
}
// The receipt is written before the intent says confirmed, so a confirmed intent always has one.
if (intent.status === "confirmed") return { confirmed: true };
if (intent.status === "unclear") return null; // the tick re-asks a person until they decide
// An intent an earlier tick left: a receipt it wrote is the answer; otherwise ask the destination by the same key.
const receipt = fresh ? null : await ctx.db.get(`receipt:${id}`, { db: DB });
const found = receipt ?? (fresh ? null : await lookUp(intent));
const result = found ?? (fresh || honorsKey(kind) ? await act(id) : { unclear: true });
if (result.confirmed) {
await ctx.db.put({ type: "receipt", intentId: id, ...result }, { db: DB, id: `receipt:${id}`, ifRev: null }).catch((err) => {
if (err.code !== "conflict") throw err; // an earlier tick wrote it before it stopped
});
}
const status = result.confirmed ? "confirmed" : result.unclear ? "unclear" : "sent";
await ctx.db.put({ ...intent, status }, { db: DB, id, ifRev: intent._rev });
return result;
}
// An intent an earlier tick left: its receipt, or the destination's answer under the same key, confirms it.
async function settle(ctx, intent) {
const found = (await ctx.db.get(`receipt:${intent._id}`, { db: DB })) ?? (await lookUp(intent));
if (!found?.confirmed) return false; // still pending; the next tick asks again
await ctx.db.put({ type: "receipt", intentId: intent._id, confirmed: true, answer: found.answer }, { db: DB, id: `receipt:${intent._id}`, ifRev: null }).catch((err) => {
if (err.code !== "conflict") throw err;
});
await ctx.db.put({ ...intent, status: "confirmed" }, { db: DB, id: intent._id, ifRev: intent._rev });
return true;
}
// Done means a receipt exists: the job says so and the owner hears it once, however the receipt arrived.
// Every step repeats safely: the job write is skipped once done, and the dedupeKey collapses the notify.
async function finish(ctx, job, intent) {
if (job.status !== "done") {
await ctx.db.put({ ...job, status: "done", waitingOn: "", receiptId: `receipt:${intent._id}` }, { db: DB, id: "job", ifRev: job._rev });
}
await notifyOwner(ctx, `done:${intent._id}`, "Done: the receipt is in.");
}
async function notifyOwner(ctx, reason, body) {
await ctx.notify({ toHandle: ctx.appInfo.ownerHandle, body, dedupeKey: `${ctx.appInfo.appSlug}:${reason}` });
}
async function allOfType(ctx, type) {
const rows = [];
let after;
do {
const page = await ctx.db.query({ db: DB, field: "type", key: type, limit: 500, after });
rows.push(...page);
after = page.next;
} while (after);
return rows;
}
The app's own logic fills in the rest:
computePlancounts the answers, picks the option that breaks no constraint and describes constraints only as counts. It returns{ summary, pick, answerCount, conflict, version }, whereversionchanges whenever the pick or the count does.send(proposal, key)takes the action and passeskeyto the destination as its idempotency key. It returns{ confirmed: true, ... }with the destination's answer, or{ unclear: true }when the call dropped.lookUp(intent)asks the destination what happened under that key, and returns{ confirmed: true, answer }or null.honorsKey(kind)is true only for a destination that honors the key.
An action happens once: claim, act, confirm
Claim an intent with ifRev: null before acting, pass its id as the
idempotency key, and write a receipt only from a confirmed answer. A
create-only put lets exactly one handler win; an access.js oldDoc check
alone can race. The claim is not the receipt.
- A tick that stops after the claim leaves an intent with no receipt. The
next tick finds it through
actOnce, asks the destination by the same key what happened, then confirms or retries. - Only a destination that honors the key can be retried blind: Stripe,
or this app's own records. Email and texts have no such key. An intent to
one of those whose send went unclear is marked
unclear, the owner is told once, and a person decides before anything is sent again. - Done is told once. When the receipt lands, the tick sets the job
donewith itsreceiptIdand notifies the owner with what happened.
Approval is a tap bound to what was shown
An approval counts only while the proposal's text, audience and amount are
unchanged. approvedProposal recomputes the hash from the current proposal
and the current member list, and compares it to the approval's hash. If
someone joined, the price moved or the words changed, the action waits and a
new proposal asks again. The proposer's own approval never counts. Taking money
or publishing to a wider audience always needs one, and an action that spends a
person's money or speaks for one person counts only that person's approval.
Actions run only in the scheduled tick, after this check.
What the page shows
- First line: the job's status in words, from the
jobrecord. - The open decision, if there is one: the proposal's text, with Approve for anyone but its proposer.
- The plan, from the
planrecord, once there is one. - "Only you see this" beside each person's constraint form (or "only you and
the app's owner", when a person rather than a group's assistant owns it). The form writes
their own
constraintrecord and nothing else. - The receipt once it exists, with what confirmed it.