@supersuit/suggest-a-change 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5 (2026-10-14)
4
+
5
+ - **The agent is Pluma.** Every word a person reads now names Pluma, the agent formerly called Freeda: "With Pluma", "Goes to <owner> and Pluma", "Pluma picks it up". The status key `with-freeda` is unchanged, so stored reports and older clients still read. A copy of the contract vendored elsewhere must be taken again: these are new bytes.
6
+
7
+ ## 0.1.4 (2026-10-14)
8
+
9
+ - **The owner may decline a report Freeda has not planned.** `FLOW["with-freeda"].next` and `FLOW["planned"].next` now include `"not-now"`, so a report the reading worker cannot plan has an exit instead of showing "With Freeda" for ever. `agentStatus` accepts both moves (they take the approval, as any end does). A copy of the contract vendored elsewhere must be taken again: these are new bytes.
10
+ - **A claim on a closed report answers 409 and never lapses.** `claim-approval` on a report at an end status answers 409 `{ closed: true, status, claimedBy }` to everyone and writes nothing; before, the seven-day approval lapsed and an eight-day-old card could take a shipped or declined report. A report being made stays with the Mac that started it: the move to `being-made` or an end now names the maintainer on its timeline entry (`holder`, not in the member's view) and takes the approval for `HELD_MS` (exported), and another Mac is answered 409 with that holder however long after. Before the work starts the approval still lapses after a week.
11
+ - **The list never offers a report another maintainer closed.** `agentList` settles such a report for the asking maintainer without showing it, so it neither raises a notice on a second Mac nor fills the head of every page; it asks the store again when that freed slots. A report another maintainer is making carries `heldBy`. A report closed before 0.1.4 names no holder and is treated as closed by someone else.
12
+
3
13
  ## 0.1.3 (2026-10-13)
4
14
 
5
15
  - **The phone's saved answers belong to whoever asked.** A list or report that the app answers after the signed-in account has changed is neither saved under the earlier account's key nor drawn: the account is asked again after the answer, as the outbox does before a write.
package/README.md CHANGED
@@ -12,7 +12,7 @@ npm install @supersuit/suggest-a-change
12
12
  import { KINDS, STATUSES, FLOW, CEILINGS } from "@supersuit/suggest-a-change";
13
13
 
14
14
  console.log(KINDS); // ["change", "broken", "feedback"]
15
- console.log(FLOW["with-freeda"]); // { actor: "mac", next: ["planned"] }
15
+ console.log(FLOW["with-freeda"]); // { actor: "mac", next: ["planned", "not-now"] }
16
16
  ```
17
17
 
18
18
  The package root is the report contract: the kinds, roles, ceilings, the status list and the flow map (who performs each step and which moves are allowed), plus the functions that validate a report against them. It has no imports, so it can also be copied whole into a place that has no install step. If you copy it, keep a hash of the copy and fail a test when the bytes change; fix the contract here and copy it again.
@@ -173,10 +173,10 @@ const h = createReportHandlers({
173
173
  | `GET /api/reports/mine` | `mine` | 200, 401 |
174
174
  | `GET /api/reports/:id` | `one(req, id)` | 200, 401, 404 (missing and not yours look the same) |
175
175
  | `POST /api/reports/:id/append` | `append(req, id)` | 200, 400, 401, 404 (not the owner's: same as missing), 409 (the suggestion has ended) |
176
- | `GET /api/agent/reports` | `agentList` | 200 `{ reports }`: one page (20, oldest first) of what is open for this Mac, each claimed for ten minutes; a report whose files are still arriving waits up to an hour |
176
+ | `GET /api/agent/reports` | `agentList` | 200 `{ reports }`: one page (20, oldest first) of what is open for this Mac, each claimed for ten minutes; a report whose files are still arriving waits up to an hour; a report another maintainer closed is never offered (it is settled for this Mac unseen); `heldBy` names the maintainer whose Mac is making a report |
177
177
  | `GET /api/agent/reports/:id/files/:file` | `agentFile(req, id, file)` | 200 `{ url, expiresAt, type, bytes, sha256 }` (the link lives five minutes), 404 |
178
178
  | `POST /api/agent/reports/:id/settle` | `agentSettle` | body `{ revision }` (the one the list gave this report): 200, 400 (no revision), 409 (not claimed, or `stale`: something was added since the list, so the report is offered again) |
179
- | `POST /api/agent/reports/:id/claim-approval` | `agentClaimApproval` | 200, or 409 `{ claimedBy }` |
179
+ | `POST /api/agent/reports/:id/claim-approval` | `agentClaimApproval` | 200, or 409 `{ claimedBy }`, or on a closed report 409 `{ closed: true, status, claimedBy? }` |
180
180
  | `POST /api/agent/reports/:id/status` | `agentStatus` | 200, 400, 409 |
181
181
 
182
182
  A client adapter's `uploadPart` asks `upload-url`, PUTs the bytes straight to storage, then calls `finalize`. Nothing is uploaded through the app's server and no handler reads a body over about 400 KB (sized from the field limits at four bytes a character; far under 4 MB).
@@ -187,7 +187,7 @@ The role stored on a report comes from the signed-in member and the `maintainers
187
187
 
188
188
  **The collector MUST verify.** `agentFile` answers the verified `bytes` and `sha256`; a Mac that downloads the link must check both before it trusts the file.
189
189
 
190
- **The approval.** Moves before the owner is asked (`with-freeda`, `planned`, `waiting-on-owner`) need no claim. Leaving `waiting-on-owner`, starting `being-made` and any end status need the Mac that holds the approval (taken atomically if nobody holds it, otherwise 409 with who does). The holder can release it, and it lapses after seven days.
190
+ **The approval.** Moves before the owner is asked (`with-freeda`, `planned`, `waiting-on-owner`) need no claim. Leaving `waiting-on-owner`, starting `being-made` and any end status need the Mac that holds the approval (taken atomically if nobody holds it, otherwise 409 with who does). Before the work starts the holder can release it, and an unrenewed approval lapses after seven days. Once the work starts or ends it does not lapse: the move to `being-made` or to an end names the maintainer on its timeline entry (`holder`, never shown to the member), the approval is taken for `HELD_MS`, and another Mac asking is answered 409 with that holder. A claim on a closed report is 409 `{ closed: true, status, claimedBy }` for everyone, the holder included, and writes nothing. The owner may decline (`not-now`) from `with-freeda` and `planned` as well as from `waiting-on-owner`.
191
191
 
192
192
  **What a storage adapter implements** (`HostedStore`). Every method that changes a report is narrow and atomic: `put` is create-only (`created`, `exists`, or `limit` when the member's day is full, counted in the same transaction), and `setFile` (never replaces a verified entry; an older pending never replaces a newer one), `addFollowUp` (the caps, a reused file name and an ended report are checked inside the write), `setStatus(expectFrom)`, `claimApproval`, `claim`, `settle` and `reopen` each change one thing. A real adapter implements these with a transaction or a precondition, never "read the report, change a copy, write it back": that rolls back a status or a claim written in between. Upload links must be create-only and one-time with the size and type fixed in their conditions (a Firebase or S3 signed PUT with `if-generation-match: 0` or `If-None-Match: *`), `discard` must delete a refused object, and `readUrl` links must expire after `ttlMs`.
193
193
 
@@ -2,7 +2,7 @@ import type { Adapters } from './adapters.ts';
2
2
  export declare function statusOf(r: Record<string, unknown>): string;
3
3
  /** The step shown under "Being made". The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
4
4
  export declare function stepOf(r: Record<string, unknown>): string | undefined;
5
- /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Freeda"),
5
+ /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
6
6
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
7
7
  export declare function StatusStrip({ status, owner, step, timeline, label }: {
8
8
  status: string;
@@ -34,7 +34,7 @@ export function stepOf(r) {
34
34
  return typeof r.step === 'string' ? r.step : undefined;
35
35
  }
36
36
  const timelineOf = (r) => Array.isArray(r?.timeline) ? r.timeline.map((t) => String(t?.status)) : undefined;
37
- /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Freeda"),
37
+ /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
38
38
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
39
39
  export function StatusStrip({ status, owner, step, timeline, label }) {
40
40
  const nodes = useMemo(() => {
@@ -27,7 +27,7 @@ export const DEFAULT_STRINGS = {
27
27
  takeScreenshot: 'Take a screenshot of this screen',
28
28
  remove: 'Remove',
29
29
  withLegend: 'What goes with it',
30
- goesTo: (owner) => `Goes to ${owner} and Freeda.`,
30
+ goesTo: (owner) => `Goes to ${owner} and Pluma.`,
31
31
  includes: (parts) => `Includes ${list(parts)}.`,
32
32
  screenName: "this screen's name",
33
33
  alwaysSent: (screen, route) => `Always sent: the screen you are on (${screen}) and its address (${route}).`,
@@ -113,7 +113,7 @@ export function statusMeaning(status, owner) {
113
113
  /** The receipt's one line (design section 2). `received` is only ever asked for after the adapter said `done`. */
114
114
  export function receiptWords(state, words) {
115
115
  if (state === 'received')
116
- return `Got it. Freeda picks it up next time ${words.owner}'s Mac is on.`;
116
+ return `Got it. Pluma picks it up next time ${words.owner}'s Mac is on.`;
117
117
  if (state === 'on-mac')
118
118
  return 'On your Mac.';
119
119
  return 'On this phone, waiting to send.';
package/dist/contract.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/contract.mjs (@supersuit/suggest-a-change)
2
2
  // THE REPORT CONTRACT AND THE FLOW MAP for Suggest a change (design sections 4, 5 and 7). ZERO
3
3
  // imports on purpose, so it can be vendored whole where an install step does not exist: the phone, the
4
- // hosted apps and Freeda's inbox all validate against this one copy. The flow lives here, with who acts
4
+ // hosted apps and Pluma's inbox all validate against this one copy. The flow lives here, with who acts
5
5
  // at each step, so what a person sees cannot drift from what happened.
6
6
  export const KINDS = ["change", "broken", "feedback"];
7
7
  export const ROLES = ["operator", "member", "maintainer"];
@@ -13,13 +13,14 @@ export const CEILINGS = {
13
13
  };
14
14
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
15
15
  export const END = ["live", "sent-to-template", "filed", "not-now"];
16
- // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Freeda by the Mac. A Helm on the owner's Mac posts straight into
17
- // the inbox, so a report may go from the phone to Freeda without the hosted app's "received".
16
+ // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
17
+ // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
18
+ // ("not-now") before Pluma has a plan or before she asks: a report she cannot plan would otherwise have no exit.
18
19
  export const FLOW = {
19
20
  "on-phone": { actor: "person", next: ["received", "with-freeda"] },
20
21
  "received": { actor: "app", next: ["with-freeda"] },
21
- "with-freeda": { actor: "mac", next: ["planned"] },
22
- "planned": { actor: "freeda", next: ["waiting-on-owner", "being-made"] },
22
+ "with-freeda": { actor: "mac", next: ["planned", "not-now"] },
23
+ "planned": { actor: "freeda", next: ["waiting-on-owner", "being-made", "not-now"] },
23
24
  "waiting-on-owner": { actor: "owner", next: ["being-made", "filed", "not-now"] },
24
25
  "being-made": { actor: "freeda", next: ["live", "sent-to-template", "filed", "not-now"] },
25
26
  "live": { actor: "freeda", next: [] },
@@ -28,14 +29,14 @@ export const FLOW = {
28
29
  "not-now": { actor: "owner", next: [] },
29
30
  };
30
31
  export const mayMove = (from, to) => Boolean(FLOW[from]?.next.includes(to));
31
- // The design's status table (section 2), word for word. "planned" is a step inside Freeda's work
32
- // that the table does not name for a person, so it reads as "With Freeda".
32
+ // The design's status table (section 2), word for word. "planned" is a step inside Pluma's work
33
+ // that the table does not name for a person, so it reads as "With Pluma".
33
34
  const TABLE = {
34
35
  "on-phone": ["On this phone", () => "Not sent yet; it will send when there is a connection"],
35
36
  "received": ["Received", () => "The app has it, safely stored"],
36
- "with-freeda": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
37
- "planned": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
38
- "waiting-on-owner": [null, (o) => `Freeda has a plan and is asking ${o}`],
37
+ "with-freeda": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
38
+ "planned": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
39
+ "waiting-on-owner": [null, (o) => `Pluma has a plan and is asking ${o}`],
39
40
  "being-made": ["Being made", () => "A session is working on it; the current step shows underneath"],
40
41
  "live": ["Done", () => "Shipped in the app, with the date it went live"],
41
42
  "sent-to-template": ["Sent to the template", () => "It became a change to the template every copy of this app uses; it reaches this app once merged"],
@@ -1,10 +1,13 @@
1
1
  import { type Limits } from './rate.ts';
2
+ /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
3
+ * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
2
4
  export interface TimelineEntry {
3
5
  status: string;
4
6
  by: string;
5
7
  at: string;
6
8
  step?: string;
7
9
  note?: string;
10
+ holder?: string;
8
11
  }
9
12
  export interface FileState {
10
13
  pending?: {
@@ -155,6 +158,8 @@ export interface ReportHandlerOptions {
155
158
  export declare const LEASE_MS: number;
156
159
  export declare const BODY_CAP: number;
157
160
  export declare const APPROVAL_LEASE_MS: number;
161
+ /** The approval's lease once the work has started or ended: in effect it never lapses (finite, so every store can keep it). */
162
+ export declare const HELD_MS: number;
158
163
  export declare const READ_TTL_MS: number;
159
164
  export declare const VOICE_FILE_BYTES: number;
160
165
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
@@ -23,6 +23,8 @@ const WORDS_MAX = 20_000;
23
23
  // A maximal report in Chinese or emoji fits; the whole is still a small fraction of the 4 MB no handler may exceed.
24
24
  export const BODY_CAP = 4 * WORDS_MAX * 4 + 64 * 1024;
25
25
  export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000;
26
+ /** The approval's lease once the work has started or ended: in effect it never lapses (finite, so every store can keep it). */
27
+ export const HELD_MS = 100 * 365 * 24 * 3600_000;
26
28
  export const READ_TTL_MS = 5 * 60_000;
27
29
  export const VOICE_FILE_BYTES = 25 * 2 ** 20;
28
30
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
@@ -74,6 +76,15 @@ async function readBody(req, cap = BODY_CAP, optional = false) {
74
76
  }
75
77
  const iso = (ms) => new Date(ms).toISOString();
76
78
  const lastStatus = (r) => r.timeline[r.timeline.length - 1]?.status ?? 'received';
79
+ /** Past asking the owner: the work has started or ended, and whoever holds the approval keeps it. */
80
+ const PAST_ASKING = ['being-made', ...END];
81
+ /** The maintainer named on the latest move that started or ended the work; none on a report from before 0.1.4. */
82
+ const holderOf = (r) => {
83
+ for (let i = r.timeline.length - 1; i >= 0; i--)
84
+ if (r.timeline[i].holder)
85
+ return r.timeline[i].holder;
86
+ return undefined;
87
+ };
77
88
  const str = (v, max) => (typeof v === 'string' && v.length <= max ? v : null);
78
89
  const fileNames = (r) => [
79
90
  ...(r.media ?? []).map((m) => m.file),
@@ -400,17 +411,36 @@ export function createReportHandlers(opts) {
400
411
  return signIn();
401
412
  const out = [];
402
413
  // A page, oldest first. Reports from before this maintainer was added are skipped when `maintainersSince` says so.
403
- for (const r of await store.listOpenFor(by, { limit: limits.agentPage * 3, since: since(by) })) {
404
- if (out.length >= limits.agentPage)
414
+ // A report another maintainer already closed is never offered: it is settled for this maintainer here, unseen, so
415
+ // it neither raises a notice on this Mac nor sits at the head of every page. Settling one frees a slot, so the
416
+ // store is asked again (a few times at most) until the page fills or nothing more was settled.
417
+ for (let round = 0; round < 4 && out.length < limits.agentPage; round++) {
418
+ const batch = await store.listOpenFor(by, { limit: limits.agentPage * 3, since: since(by) });
419
+ let settled = 0;
420
+ for (const r of batch) {
421
+ if (out.length >= limits.agentPage)
422
+ break;
423
+ if (END.includes(lastStatus(r)) && holderOf(r) !== by) {
424
+ await store.claim(r.id, by, LEASE_MS);
425
+ if ((await store.settle(r.id, by, reportRevision(r))) === 'settled')
426
+ settled++;
427
+ continue;
428
+ }
429
+ // A report still waiting for its attachments is held back, so the Mac never copies half of one.
430
+ if (!complete(r) && now() - Date.parse(r.receivedAt) < INCOMPLETE_GRACE_MS)
431
+ continue;
432
+ await store.claim(r.id, by, LEASE_MS);
433
+ out.push(r);
434
+ }
435
+ if (!settled || batch.length < limits.agentPage * 3)
405
436
  break;
406
- // A report still waiting for its attachments is held back, so the Mac never copies half of one.
407
- if (!complete(r) && now() - Date.parse(r.receivedAt) < INCOMPLETE_GRACE_MS)
408
- continue;
409
- await store.claim(r.id, by, LEASE_MS);
410
- out.push(r);
411
437
  }
412
438
  // `revision` is what this Mac is being shown; it goes back with the settle so a report that grew in between is not settled.
413
- return json(200, { reports: out.map((r) => ({ ...r, revision: reportRevision(r) })) });
439
+ // `heldBy` names another maintainer whose Mac is making the report, so this Mac files it as taken.
440
+ return json(200, { reports: out.map((r) => {
441
+ const holder = lastStatus(r) === 'being-made' ? holderOf(r) : undefined;
442
+ return { ...r, revision: reportRevision(r), ...(holder && holder !== by ? { heldBy: holder } : {}) };
443
+ }) });
414
444
  }
415
445
  async function agentFile(req, id, file) {
416
446
  const m = wrongMethod(req, 'GET');
@@ -455,6 +485,19 @@ export function createReportHandlers(opts) {
455
485
  await tell({ type: 'settled', reportId: id });
456
486
  return json(200, { id, status: moves ? 'with-freeda' : lastStatus(r) });
457
487
  }
488
+ /** Takes (or renews) the approval for `by`. A closed report is never taken, by anyone: it answers who closed it. A
489
+ * report being made stays with the Mac that started it. Taken for a move that starts or ends the work, or on a
490
+ * report already being made, the lease is HELD_MS, so it does not lapse; before that, a week. Writes nothing when refused. */
491
+ async function takeApproval(r, by, to) {
492
+ const from = lastStatus(r);
493
+ const holder = holderOf(r);
494
+ if (END.includes(from))
495
+ return { closed: from, ...(holder ? { claimedBy: holder } : {}) };
496
+ if (from === 'being-made' && holder && holder !== by)
497
+ return { claimedBy: holder };
498
+ const lasting = from === 'being-made' || (to !== undefined && PAST_ASKING.includes(to));
499
+ return store.claimApproval(r.id, by, lasting ? HELD_MS : APPROVAL_LEASE_MS);
500
+ }
458
501
  async function agentClaimApproval(req, id) {
459
502
  const m = wrongMethod(req, 'POST');
460
503
  if (m)
@@ -465,9 +508,12 @@ export function createReportHandlers(opts) {
465
508
  const body = await readBody(req, BODY_CAP, true);
466
509
  if ('res' in body)
467
510
  return body.res;
468
- if (!isReportId(id) || !(await store.get(id)))
511
+ const r = isReportId(id) ? await store.get(id) : null;
512
+ if (!r)
469
513
  return notFound();
470
- const got = await store.claimApproval(id, by, APPROVAL_LEASE_MS);
514
+ const got = await takeApproval(r, by);
515
+ if ('closed' in got)
516
+ return json(409, { error: 'this suggestion is closed', closed: true, status: got.closed, ...(got.claimedBy ? { claimedBy: got.claimedBy } : {}) });
471
517
  return 'ok' in got ? json(200, { ok: true, claimedBy: by }) : json(409, { claimedBy: got.claimedBy });
472
518
  }
473
519
  async function agentReleaseApproval(req, id) {
@@ -511,14 +557,21 @@ export function createReportHandlers(opts) {
511
557
  return json(409, { error: `a report cannot go from ${from} to ${status}` });
512
558
  // Once the owner is being asked, the Mac that holds the approval decides what happens next. Leaving
513
559
  // waiting-on-owner, starting the work and ending it need the claim (taken here if nobody holds it).
514
- if (from === 'waiting-on-owner' || status === 'being-made' || END.includes(status)) {
515
- const c = await store.claimApproval(id, by, APPROVAL_LEASE_MS);
560
+ const needsApproval = from === 'waiting-on-owner' || PAST_ASKING.includes(status);
561
+ if (needsApproval) {
562
+ const c = await takeApproval(r, by, status);
516
563
  if (!('ok' in c))
517
- return json(409, { error: 'another Mac holds the approval for this report', claimedBy: c.claimedBy });
564
+ return json(409, { error: 'another Mac holds the approval for this report', ...(c.claimedBy ? { claimedBy: c.claimedBy } : {}) });
518
565
  }
519
- const moved = await store.setStatus(id, { status, by: FLOW[status].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}) }, from);
520
- if (!moved)
566
+ // The maintainer is named on a move that starts or ends the work, so the hold is known from the report itself.
567
+ const holder = PAST_ASKING.includes(status) ? { holder: by } : {};
568
+ const moved = await store.setStatus(id, { status, by: FLOW[status].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder }, from);
569
+ if (!moved) {
570
+ // The lasting lease was taken for a move that did not happen: bring it back to a week.
571
+ if (needsApproval && from !== 'being-made' && PAST_ASKING.includes(status))
572
+ await store.claimApproval(id, by, APPROVAL_LEASE_MS);
521
573
  return json(409, { error: 'the report changed while this was sent; read it again' });
574
+ }
522
575
  await tell({ type: 'status', reportId: id });
523
576
  return json(200, { id, status });
524
577
  }
@@ -1,4 +1,4 @@
1
- export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
1
+ export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
2
2
  export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts';
3
3
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
4
4
  export { effectiveLimits } from './rate.ts';
@@ -1,5 +1,5 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
- export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
2
+ export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
3
3
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
4
4
  export { effectiveLimits } from './rate.js';
5
5
  export { runStoreConformance, checks as conformanceChecks } from './conformance.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@ export function stepOf(r: Record<string, unknown>): string | undefined {
30
30
  const timelineOf = (r?: Record<string, unknown>): string[] | undefined =>
31
31
  Array.isArray(r?.timeline) ? (r!.timeline as { status?: unknown }[]).map((t) => String(t?.status)) : undefined
32
32
 
33
- /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Freeda"),
33
+ /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
34
34
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
35
35
  export function StatusStrip({ status, owner, step, timeline, label }: { status: string; owner: string; step?: string; timeline?: string[]; /** The list's accessible name; the screens pass the app's own (words.text.stepLabel). */ label?: string }) {
36
36
  const nodes = useMemo(() => {
@@ -122,7 +122,7 @@ export const DEFAULT_STRINGS: Strings = {
122
122
  takeScreenshot: 'Take a screenshot of this screen',
123
123
  remove: 'Remove',
124
124
  withLegend: 'What goes with it',
125
- goesTo: (owner) => `Goes to ${owner} and Freeda.`,
125
+ goesTo: (owner) => `Goes to ${owner} and Pluma.`,
126
126
  includes: (parts) => `Includes ${list(parts)}.`,
127
127
  screenName: "this screen's name",
128
128
  alwaysSent: (screen, route) => `Always sent: the screen you are on (${screen}) and its address (${route}).`,
@@ -202,7 +202,7 @@ export type ReceiptState = 'on-phone' | 'received' | 'on-mac'
202
202
 
203
203
  /** The receipt's one line (design section 2). `received` is only ever asked for after the adapter said `done`. */
204
204
  export function receiptWords(state: ReceiptState, words: Pick<Words, 'owner'>): string {
205
- if (state === 'received') return `Got it. Freeda picks it up next time ${words.owner}'s Mac is on.`
205
+ if (state === 'received') return `Got it. Pluma picks it up next time ${words.owner}'s Mac is on.`
206
206
  if (state === 'on-mac') return 'On your Mac.'
207
207
  return 'On this phone, waiting to send.'
208
208
  }
package/src/contract.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/contract.mjs (@supersuit/suggest-a-change)
2
2
  // THE REPORT CONTRACT AND THE FLOW MAP for Suggest a change (design sections 4, 5 and 7). ZERO
3
3
  // imports on purpose, so it can be vendored whole where an install step does not exist: the phone, the
4
- // hosted apps and Freeda's inbox all validate against this one copy. The flow lives here, with who acts
4
+ // hosted apps and Pluma's inbox all validate against this one copy. The flow lives here, with who acts
5
5
  // at each step, so what a person sees cannot drift from what happened.
6
6
 
7
7
  export const KINDS = ["change", "broken", "feedback"];
@@ -17,13 +17,14 @@ export const CEILINGS = {
17
17
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
18
18
  export const END = ["live", "sent-to-template", "filed", "not-now"];
19
19
 
20
- // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Freeda by the Mac. A Helm on the owner's Mac posts straight into
21
- // the inbox, so a report may go from the phone to Freeda without the hosted app's "received".
20
+ // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
21
+ // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
22
+ // ("not-now") before Pluma has a plan or before she asks: a report she cannot plan would otherwise have no exit.
22
23
  export const FLOW = {
23
24
  "on-phone": { actor: "person", next: ["received", "with-freeda"] },
24
25
  "received": { actor: "app", next: ["with-freeda"] },
25
- "with-freeda": { actor: "mac", next: ["planned"] },
26
- "planned": { actor: "freeda", next: ["waiting-on-owner", "being-made"] },
26
+ "with-freeda": { actor: "mac", next: ["planned", "not-now"] },
27
+ "planned": { actor: "freeda", next: ["waiting-on-owner", "being-made", "not-now"] },
27
28
  "waiting-on-owner": { actor: "owner", next: ["being-made", "filed", "not-now"] },
28
29
  "being-made": { actor: "freeda", next: ["live", "sent-to-template", "filed", "not-now"] },
29
30
  "live": { actor: "freeda", next: [] },
@@ -34,14 +35,14 @@ export const FLOW = {
34
35
 
35
36
  export const mayMove = (from, to) => Boolean(FLOW[from]?.next.includes(to));
36
37
 
37
- // The design's status table (section 2), word for word. "planned" is a step inside Freeda's work
38
- // that the table does not name for a person, so it reads as "With Freeda".
38
+ // The design's status table (section 2), word for word. "planned" is a step inside Pluma's work
39
+ // that the table does not name for a person, so it reads as "With Pluma".
39
40
  const TABLE = {
40
41
  "on-phone": ["On this phone", () => "Not sent yet; it will send when there is a connection"],
41
42
  "received": ["Received", () => "The app has it, safely stored"],
42
- "with-freeda": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
43
- "planned": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
44
- "waiting-on-owner": [null, (o) => `Freeda has a plan and is asking ${o}`],
43
+ "with-freeda": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
44
+ "planned": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
45
+ "waiting-on-owner": [null, (o) => `Pluma has a plan and is asking ${o}`],
45
46
  "being-made": ["Being made", () => "A session is working on it; the current step shows underneath"],
46
47
  "live": ["Done", () => "Shipped in the app, with the date it went live"],
47
48
  "sent-to-template": ["Sent to the template", () => "It became a change to the template every copy of this app uses; it reaches this app once merged"],
@@ -11,7 +11,9 @@ import { END, FLOW, STATUSES, isAcceptedMedia, isReportId, mayMove, validateRepo
11
11
  import { effectiveLimits, type Limits } from './rate.ts'
12
12
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.ts'
13
13
 
14
- export interface TimelineEntry { status: string; by: string; at: string; step?: string; note?: string }
14
+ /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
15
+ * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
16
+ export interface TimelineEntry { status: string; by: string; at: string; step?: string; note?: string; holder?: string }
15
17
  export interface FileState { pending?: { type: string; bytes: number; issuedAt?: number }; type?: string; bytes?: number; sha256?: string; at?: string }
16
18
  export interface FollowUp { followId: string; words: string; at: string; by: string; voice: { file: string; ms: number }[] }
17
19
  export interface StoredReport {
@@ -105,6 +107,8 @@ const WORDS_MAX = 20_000
105
107
  // A maximal report in Chinese or emoji fits; the whole is still a small fraction of the 4 MB no handler may exceed.
106
108
  export const BODY_CAP = 4 * WORDS_MAX * 4 + 64 * 1024
107
109
  export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000
110
+ /** The approval's lease once the work has started or ended: in effect it never lapses (finite, so every store can keep it). */
111
+ export const HELD_MS = 100 * 365 * 24 * 3600_000
108
112
  export const READ_TTL_MS = 5 * 60_000
109
113
  export const VOICE_FILE_BYTES = 25 * 2 ** 20
110
114
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
@@ -149,6 +153,13 @@ async function readBody(req: Request, cap = BODY_CAP, optional = false): Promise
149
153
 
150
154
  const iso = (ms: number) => new Date(ms).toISOString()
151
155
  const lastStatus = (r: StoredReport) => r.timeline[r.timeline.length - 1]?.status ?? 'received'
156
+ /** Past asking the owner: the work has started or ended, and whoever holds the approval keeps it. */
157
+ const PAST_ASKING = ['being-made', ...END]
158
+ /** The maintainer named on the latest move that started or ended the work; none on a report from before 0.1.4. */
159
+ const holderOf = (r: StoredReport): string | undefined => {
160
+ for (let i = r.timeline.length - 1; i >= 0; i--) if (r.timeline[i].holder) return r.timeline[i].holder
161
+ return undefined
162
+ }
152
163
  const str = (v: unknown, max: number): string | null => (typeof v === 'string' && v.length <= max ? v : null)
153
164
  const fileNames = (r: StoredReport): string[] => [
154
165
  ...((r.media as { file: string }[] | undefined) ?? []).map((m) => m.file),
@@ -382,15 +393,32 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
382
393
  const by = await maintainerFor(req); if (!by) return signIn()
383
394
  const out: StoredReport[] = []
384
395
  // A page, oldest first. Reports from before this maintainer was added are skipped when `maintainersSince` says so.
385
- for (const r of await store.listOpenFor(by, { limit: limits.agentPage * 3, since: since(by) })) {
386
- if (out.length >= limits.agentPage) break
387
- // A report still waiting for its attachments is held back, so the Mac never copies half of one.
388
- if (!complete(r) && now() - Date.parse(r.receivedAt) < INCOMPLETE_GRACE_MS) continue
389
- await store.claim(r.id, by, LEASE_MS)
390
- out.push(r)
396
+ // A report another maintainer already closed is never offered: it is settled for this maintainer here, unseen, so
397
+ // it neither raises a notice on this Mac nor sits at the head of every page. Settling one frees a slot, so the
398
+ // store is asked again (a few times at most) until the page fills or nothing more was settled.
399
+ for (let round = 0; round < 4 && out.length < limits.agentPage; round++) {
400
+ const batch = await store.listOpenFor(by, { limit: limits.agentPage * 3, since: since(by) })
401
+ let settled = 0
402
+ for (const r of batch) {
403
+ if (out.length >= limits.agentPage) break
404
+ if (END.includes(lastStatus(r)) && holderOf(r) !== by) {
405
+ await store.claim(r.id, by, LEASE_MS)
406
+ if ((await store.settle(r.id, by, reportRevision(r))) === 'settled') settled++
407
+ continue
408
+ }
409
+ // A report still waiting for its attachments is held back, so the Mac never copies half of one.
410
+ if (!complete(r) && now() - Date.parse(r.receivedAt) < INCOMPLETE_GRACE_MS) continue
411
+ await store.claim(r.id, by, LEASE_MS)
412
+ out.push(r)
413
+ }
414
+ if (!settled || batch.length < limits.agentPage * 3) break
391
415
  }
392
416
  // `revision` is what this Mac is being shown; it goes back with the settle so a report that grew in between is not settled.
393
- return json(200, { reports: out.map((r) => ({ ...r, revision: reportRevision(r) })) })
417
+ // `heldBy` names another maintainer whose Mac is making the report, so this Mac files it as taken.
418
+ return json(200, { reports: out.map((r) => {
419
+ const holder = lastStatus(r) === 'being-made' ? holderOf(r) : undefined
420
+ return { ...r, revision: reportRevision(r), ...(holder && holder !== by ? { heldBy: holder } : {}) }
421
+ }) })
394
422
  }
395
423
 
396
424
  async function agentFile(req: Request, id: string, file: string): Promise<Response> {
@@ -422,12 +450,26 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
422
450
  return json(200, { id, status: moves ? 'with-freeda' : lastStatus(r) })
423
451
  }
424
452
 
453
+ /** Takes (or renews) the approval for `by`. A closed report is never taken, by anyone: it answers who closed it. A
454
+ * report being made stays with the Mac that started it. Taken for a move that starts or ends the work, or on a
455
+ * report already being made, the lease is HELD_MS, so it does not lapse; before that, a week. Writes nothing when refused. */
456
+ async function takeApproval(r: StoredReport, by: string, to?: string): Promise<{ ok: true } | { closed: string; claimedBy?: string } | { claimedBy: string }> {
457
+ const from = lastStatus(r)
458
+ const holder = holderOf(r)
459
+ if (END.includes(from)) return { closed: from, ...(holder ? { claimedBy: holder } : {}) }
460
+ if (from === 'being-made' && holder && holder !== by) return { claimedBy: holder }
461
+ const lasting = from === 'being-made' || (to !== undefined && PAST_ASKING.includes(to))
462
+ return store.claimApproval(r.id, by, lasting ? HELD_MS : APPROVAL_LEASE_MS)
463
+ }
464
+
425
465
  async function agentClaimApproval(req: Request, id: string): Promise<Response> {
426
466
  const m = wrongMethod(req, 'POST'); if (m) return m
427
467
  const by = await maintainerFor(req); if (!by) return signIn()
428
468
  const body = await readBody(req, BODY_CAP, true); if ('res' in body) return body.res
429
- if (!isReportId(id) || !(await store.get(id))) return notFound()
430
- const got = await store.claimApproval(id, by, APPROVAL_LEASE_MS)
469
+ const r = isReportId(id) ? await store.get(id) : null
470
+ if (!r) return notFound()
471
+ const got = await takeApproval(r, by)
472
+ if ('closed' in got) return json(409, { error: 'this suggestion is closed', closed: true, status: got.closed, ...(got.claimedBy ? { claimedBy: got.claimedBy } : {}) })
431
473
  return 'ok' in got ? json(200, { ok: true, claimedBy: by }) : json(409, { claimedBy: got.claimedBy })
432
474
  }
433
475
 
@@ -455,12 +497,19 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
455
497
  if (!mayMove(from, status)) return json(409, { error: `a report cannot go from ${from} to ${status}` })
456
498
  // Once the owner is being asked, the Mac that holds the approval decides what happens next. Leaving
457
499
  // waiting-on-owner, starting the work and ending it need the claim (taken here if nobody holds it).
458
- if (from === 'waiting-on-owner' || status === 'being-made' || END.includes(status)) {
459
- const c = await store.claimApproval(id, by, APPROVAL_LEASE_MS)
460
- if (!('ok' in c)) return json(409, { error: 'another Mac holds the approval for this report', claimedBy: c.claimedBy })
500
+ const needsApproval = from === 'waiting-on-owner' || PAST_ASKING.includes(status)
501
+ if (needsApproval) {
502
+ const c = await takeApproval(r, by, status)
503
+ if (!('ok' in c)) return json(409, { error: 'another Mac holds the approval for this report', ...(c.claimedBy ? { claimedBy: c.claimedBy } : {}) })
504
+ }
505
+ // The maintainer is named on a move that starts or ends the work, so the hold is known from the report itself.
506
+ const holder = PAST_ASKING.includes(status) ? { holder: by } : {}
507
+ const moved = await store.setStatus(id, { status, by: FLOW[status as keyof typeof FLOW].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}), ...holder }, from)
508
+ if (!moved) {
509
+ // The lasting lease was taken for a move that did not happen: bring it back to a week.
510
+ if (needsApproval && from !== 'being-made' && PAST_ASKING.includes(status)) await store.claimApproval(id, by, APPROVAL_LEASE_MS)
511
+ return json(409, { error: 'the report changed while this was sent; read it again' })
461
512
  }
462
- const moved = await store.setStatus(id, { status, by: FLOW[status as keyof typeof FLOW].actor, at: iso(now()), ...(step ? { step } : {}), ...(note ? { note } : {}) }, from)
463
- if (!moved) return json(409, { error: 'the report changed while this was sent; read it again' })
464
513
  await tell({ type: 'status', reportId: id })
465
514
  return json(200, { id, status })
466
515
  }
@@ -1,5 +1,5 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
- export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
2
+ export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
3
3
  export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts'
4
4
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
5
5
  export { effectiveLimits } from './rate.ts'