vibes.diy/docs Home Discord
creator docs · reference · generated

Group plans: collect, decide, act once, report

This is the same document the Vibes code generator reads when it builds apps — rendered from 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, done or stopped), 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) and receiptId once done. A page sets goal, deadline and quorum, and may lower checksLeft to stop early. status, waitingOn and receiptId move 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"). Its authorHandle is the only member who reads it.
  • staleness, _id: "staleness", written only by onChange: 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 _rev is what the plan was computed from.
  • plan, _id: "plan", written only by backend.js: the outcome and nothing else ("Meet at the north gate at 4. Leave by 5:40."), plus basedOn, the staleness rev 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), its status (claimed, sent, confirmed or unclear) and its idempotencyKey, 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:

  • computePlan counts the answers, picks the option that breaks no constraint and describes constraints only as counts. It returns { summary, pick, answerCount, conflict, version }, where version changes whenever the pick or the count does.
  • send(proposal, key) takes the action and passes key to 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 done with its receiptId and 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 job record.
  • The open decision, if there is one: the proposal's text, with Approve for anyone but its proposer.
  • The plan, from the plan record, 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 constraint record and nothing else.
  • The receipt once it exists, with what confirmed it.