@fitzzero/quickdraw-core 5.0.0 → 5.1.0

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.
Files changed (68) hide show
  1. package/README.md +343 -49
  2. package/dist/{admin-BiU4LRZS.d.ts → admin-DKwkIh5i.d.ts} +25 -10
  3. package/dist/{adminSchemas-k5a1Ov7b.d.ts → adminSchemas-2DUA_hZ8.d.ts} +15 -9
  4. package/dist/{auth-CMyRkfjZ.d.ts → auth-BhWqdyb-.d.ts} +2 -2
  5. package/dist/{caller-DHzK3oiI.d.ts → caller-CXTFswBO.d.ts} +28 -6
  6. package/dist/{chunk-2XJFNVHW.js → chunk-2PWMUVC7.js} +1 -1
  7. package/dist/{chunk-2XJFNVHW.js.map → chunk-2PWMUVC7.js.map} +1 -1
  8. package/dist/{chunk-5IC5VZ7T.js → chunk-2UELNHDD.js} +3 -3
  9. package/dist/{chunk-XFDOGTMC.js → chunk-3674EIRT.js} +4 -4
  10. package/dist/{chunk-DNCAI3KY.js → chunk-4DIQHASK.js} +1 -1
  11. package/dist/chunk-4DIQHASK.js.map +1 -0
  12. package/dist/{chunk-LLLYILEP.js → chunk-A6DJBGOG.js} +411 -43
  13. package/dist/chunk-A6DJBGOG.js.map +1 -0
  14. package/dist/{chunk-B2UQ27GH.js → chunk-CDKG4TFA.js} +25 -5
  15. package/dist/{chunk-B2UQ27GH.js.map → chunk-CDKG4TFA.js.map} +1 -1
  16. package/dist/{chunk-IRGAGRVG.js → chunk-JZBCLNDU.js} +4 -4
  17. package/dist/{chunk-XUSN5WKU.js → chunk-KNDDCVQJ.js} +4 -4
  18. package/dist/{chunk-5YRQ26LF.js → chunk-RT6OILOI.js} +1 -1
  19. package/dist/chunk-RT6OILOI.js.map +1 -0
  20. package/dist/{chunk-7QIY2DDX.js → chunk-VJTFXHOK.js} +2 -2
  21. package/dist/{chunk-7QIY2DDX.js.map → chunk-VJTFXHOK.js.map} +1 -1
  22. package/dist/chunk-XNLWBZIG.js +9 -0
  23. package/dist/chunk-XNLWBZIG.js.map +1 -0
  24. package/dist/cli/quickdraw-docs.js +241 -27
  25. package/dist/cli/quickdraw-docs.js.map +1 -1
  26. package/dist/client/index.d.ts +7 -7
  27. package/dist/client/index.js +5 -5
  28. package/dist/{clientTypes-B3o_99Xq.d.ts → clientTypes-Bc-G8n-U.d.ts} +3 -3
  29. package/dist/{connection-DuN8GvcD.d.ts → connection-Cr1BU07W.d.ts} +2 -2
  30. package/dist/{createServer-DUacdfKl.d.ts → createServer-CP9eDHZZ.d.ts} +30 -9
  31. package/dist/{createTestApp-DFLXoTKm.d.ts → createTestApp-UKVGEmOf.d.ts} +6 -6
  32. package/dist/{envelope-nf5eGB-a.d.ts → envelope-Cp3YcVxC.d.ts} +1 -1
  33. package/dist/{http--uoooIH5.d.ts → http-Djj2Inuu.d.ts} +6 -6
  34. package/dist/index.d.ts +6 -6
  35. package/dist/index.js +27 -7
  36. package/dist/index.js.map +1 -1
  37. package/dist/{infer-Dw21kmLw.d.ts → infer-Teo1xpsd.d.ts} +17 -0
  38. package/dist/{metrics-C0WvB7-I.d.ts → metrics-BdBu5o8G.d.ts} +2 -2
  39. package/dist/prisma/index.d.ts +4 -4
  40. package/dist/prisma/index.js +1 -1
  41. package/dist/server/auth/index.d.ts +14 -5
  42. package/dist/server/auth/index.js +0 -5
  43. package/dist/server/auth/index.js.map +1 -1
  44. package/dist/server/index.d.ts +120 -33
  45. package/dist/server/index.js +146 -193
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/server/mcp/index.d.ts +51 -11
  48. package/dist/server/mcp/index.js +129 -8
  49. package/dist/server/mcp/index.js.map +1 -1
  50. package/dist/server/otel.d.ts +3 -3
  51. package/dist/{sharing-n4ss-ONM.d.ts → sharing-DVa8W-yR.d.ts} +3 -3
  52. package/dist/testing/client.d.ts +13 -13
  53. package/dist/testing/client.js +6 -6
  54. package/dist/testing/index.d.ts +156 -12
  55. package/dist/testing/index.js +1037 -22
  56. package/dist/testing/index.js.map +1 -1
  57. package/dist/testing/mock.d.ts +5 -5
  58. package/dist/testing/mock.js +5 -5
  59. package/dist/{types-Bwg3kIEc.d.ts → types-Cu0vmx7c.d.ts} +8 -2
  60. package/dist/utils/index.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/dist/chunk-5YRQ26LF.js.map +0 -1
  63. package/dist/chunk-DNCAI3KY.js.map +0 -1
  64. package/dist/chunk-LLLYILEP.js.map +0 -1
  65. /package/dist/{chunk-5IC5VZ7T.js.map → chunk-2UELNHDD.js.map} +0 -0
  66. /package/dist/{chunk-XFDOGTMC.js.map → chunk-3674EIRT.js.map} +0 -0
  67. /package/dist/{chunk-IRGAGRVG.js.map → chunk-JZBCLNDU.js.map} +0 -0
  68. /package/dist/{chunk-XUSN5WKU.js.map → chunk-KNDDCVQJ.js.map} +0 -0
package/README.md CHANGED
@@ -77,6 +77,8 @@ import { z } from "zod";
77
77
  import { cardSchema, taskSchema } from "../schemas";
78
78
 
79
79
  export const taskContract = defineContract("taskService", {
80
+ // what the service is for: MCP tools and the generated docs show it
81
+ describe: "Tasks on a project's board.",
80
82
  // the full row; it must contain `id: string`
81
83
  entity: taskSchema,
82
84
  // lean shapes of the row
@@ -103,11 +105,13 @@ export const taskContract = defineContract("taskService", {
103
105
  output: z.number(),
104
106
  // fetched again whenever the project's board changes
105
107
  watch: { collection: "board", scope: (input) => input.projectId },
108
+ describe: "Counts the tasks on a project's board.",
106
109
  }),
107
110
  },
108
111
  collections: {
109
112
  // every task of a project, live, in board order
110
113
  board: {
114
+ describe: "A project's tasks, in board order.",
111
115
  scope: "projectId",
112
116
  item: "card",
113
117
  order: [
@@ -482,25 +486,49 @@ any server code (design: `docs/rfcs/0003-v5.md`, section 2).
482
486
 
483
487
  ```ts
484
488
  export const labelContract = defineContract("labelService", {
489
+ describe: "Labels a project puts on its tasks.",
485
490
  entity: labelSchema,
486
491
  projections: { chip: z.object({ id: z.string(), name: z.string() }) },
487
492
  methods: {
488
- find: query({ input: z.object({ name: z.string() }), output: nullable("entity") }),
489
- chips: query({ input: z.object({ projectId: z.string() }), output: listOf("chip") }),
490
- usage: query({ input: z.undefined(), output: z.record(z.string(), z.number()) }),
493
+ find: query({
494
+ input: z.object({ name: z.string() }),
495
+ output: nullable("entity"),
496
+ describe: "Finds a label by its name, or null.",
497
+ }),
498
+ chips: query({
499
+ input: z.object({ projectId: z.string() }),
500
+ output: listOf("chip"),
501
+ describe: "Lists a project's labels as chips.",
502
+ }),
503
+ usage: query({
504
+ input: z.undefined(),
505
+ output: z.record(z.string(), z.number()),
506
+ describe: "Counts the tasks of each label.",
507
+ }),
491
508
  },
492
509
  });
493
510
 
494
511
  // No entity: an RPC-only service, with no projections, field tiers or collections.
495
512
  export const healthContract = defineContract("healthService", {
496
- methods: { ping: query({ input: z.undefined(), output: z.literal("pong") }) },
513
+ describe: "Tells a caller the server is up.",
514
+ methods: {
515
+ ping: query({
516
+ input: z.undefined(),
517
+ output: z.literal("pong"),
518
+ describe: "Answers pong while the server runs.",
519
+ }),
520
+ },
497
521
  });
498
522
  ```
499
523
 
500
524
  - Every method is a `query` or a `mutation`, with an `input` and an
501
525
  `output`. The kind decides request sharing, cancellation, the concurrency
502
- cap, the MCP read-only hint and which client hook exists. `describe` is the
503
- method's description for people and agents (the MCP tool's description).
526
+ cap, the MCP read-only hint and which client hook exists.
527
+ - Every member takes a `describe`, a sentence or two for people and agents:
528
+ the contract itself (what the service is for), each method (its MCP tool's
529
+ description), collection, stream, channel and event. `quickdraw-docs`
530
+ leads each section of the API docs with it. It is optional in the types;
531
+ lint's `require-describe` (a warning) lists each member without one.
504
532
  - `output` is a schema, or a projection: `"entity"`, a named projection,
505
533
  `nullable("entity")` or `listOf("card")`. A handler returns database rows
506
534
  for a projection output, and the framework projects them.
@@ -693,10 +721,20 @@ export const taskService = qd.defineService(task, {
693
721
  handler: async ({ db }) => (await db.task.updateMany({ data: { status: "archived" } })).count,
694
722
  },
695
723
  claim: {
696
- access: custom((ctx, input) => input.id.length > 0 && ctx.principal.kind === "user"),
724
+ access: { entry: "Moderate" },
725
+ // users only: an agent acting for one is refused, whatever its grants
726
+ kinds: ["user"],
697
727
  handler: ({ input, ctx, db }) =>
698
728
  db.task.update({ where: { id: input.id }, data: { assigneeId: ctx.principal.userId } }),
699
729
  },
730
+ triage: {
731
+ // a check on a token's claims stays a custom form
732
+ access: custom(
733
+ (ctx, input) => ctx.principal.claims?.scope === "triage" && input.status !== "archived",
734
+ ),
735
+ handler: ({ input, db }) =>
736
+ db.task.update({ where: { id: input.id }, data: { status: input.status } }),
737
+ },
700
738
  },
701
739
  });
702
740
  ```
@@ -708,6 +746,20 @@ export const taskService = qd.defineService(task, {
708
746
  `custom(fn)`. Without a principal every form but `"public"` answers
709
747
  `UNAUTHENTICATED`; a principal that fails gets `FORBIDDEN`. The levels,
710
748
  lowest first, are `Public`, `Read`, `Moderate` and `Admin`.
749
+ - `kinds`, beside `access`, says which kinds of principal
750
+ (`principal.kind`) may call: `initQuickdraw({ kinds: ["user", "agent"] })`
751
+ for every service the app defines, `defineService(contract, { kinds })`
752
+ for one service, and a method's own `kinds` (`claim` above). Each level
753
+ only narrows the one above it: a wider or empty list is refused when the
754
+ service is defined. A principal of another kind, or without a `kind`,
755
+ gets `FORBIDDEN` before the form is asked, and no grant passes the check,
756
+ a service-wide `Admin` grant included, since a token acting with someone's
757
+ grants is what it stops. An anonymous caller is left to the form, so a
758
+ `"public"` method takes no `kinds` of its own: a refused caller would call
759
+ it signed out. A service's list also holds for its entity, collection,
760
+ topic and stream subscriptions, and its channels drop the messages of
761
+ other kinds. A check on a claim, such as a token's `scope`, stays a
762
+ `custom` form (`triage` above).
711
763
  - On a service with a policy, a method whose input has `id` under a form
712
764
  that checks no row (`"public"`, `"authenticated"`, `{ service: L }` below
713
765
  `Admin`) would let anyone that form admits reach any row by its id, so
@@ -719,7 +771,8 @@ export const taskService = qd.defineService(task, {
719
771
  `rowless: ["get"]` in the kit's options. The input's keys come from its
720
772
  JSON Schema: an `id` in any branch of a union counts, and so does one
721
773
  beside a value JSON Schema cannot write (a `Date`, a `Set`). Not checked:
722
- an input without JSON Schema (Zod 3), one that is no object (a bare string
774
+ an input without JSON Schema (Zod 3) or whose JSON Schema names no keys (a
775
+ `todoSchema` without `keys`), one that is no object (a bare string
723
776
  that is the id itself) and a row named by another key (`ids`, `taskId`, a
724
777
  nested `where.id`).
725
778
  - A service-wide `Admin` grant passes every check on its service
@@ -734,16 +787,23 @@ export const taskService = qd.defineService(task, {
734
787
  (`qd:access`), and in-process callers load the grants again.
735
788
  - Policies: `owner(field)`, `jsonAcl(field, { owner? })`,
736
789
  `members({ model, entry, user, level, levels? })`, `inherit({ from, via })`,
737
- `anyOf(...)`, `resolver({ levelsFor, where? })` and `everyone(level)`
790
+ `anyOf(...)`, `resolver({ levelsFor, where?, reads })` and `everyone(level)`
738
791
  (every signed-in user has `level` on every row, reading nothing: public
739
792
  profiles are `anyOf(owner("id"), everyone("Read"))`; unlike `rowless: true`
740
- on a method, it covers subscriptions and lists too). Their column names are
741
- checked against the Prisma client's models at compile time. A lookup is one
742
- batched query per table, memoized for the call, so checking 60 ids costs
743
- what checking one does. `entry` access needs a policy; a service without
744
- `model` may only use `"public"`, `"authenticated"`, `{ service }` and
745
- `custom`. `inherit` uses the parent's policy only: grants on the parent's
746
- service do not flow down.
793
+ on a method, it covers subscriptions and lists too). A `resolver` is app
794
+ code, so it says what its levels depend on: `reads: { columns, memberships }`
795
+ names columns of the service's model and membership tables as `members`
796
+ takes them (`entry` holding this service's row id), and tracked writes to
797
+ them re-check what it decided, as for the other policies; `reads: "none"`
798
+ says nothing a tracked write changes can change a level (the principal's
799
+ grants alone, say). A resolver with neither is never re-checked, and the
800
+ server warns `[quickdraw:resolver-without-reads]` when it starts. Their
801
+ column and table names are checked against the Prisma client's models at
802
+ compile time. A lookup is one batched query per table, memoized for the
803
+ call, so checking 60 ids costs what checking one does. `entry` access
804
+ needs a policy; a service without `model` may only use `"public"`,
805
+ `"authenticated"`, `{ service }` and `custom`. `inherit` uses the
806
+ parent's policy only: grants on the parent's service do not flow down.
747
807
  - One policy decides every surface: method calls, entity subscriptions,
748
808
  collection scopes, the kits' lists and searches, streams and channels.
749
809
  - When a tracked write lowers or removes someone's access, their sockets
@@ -970,11 +1030,13 @@ authorizes a scope:
970
1030
 
971
1031
  ```ts
972
1032
  export const task = defineContract("taskService", {
1033
+ describe: "Tasks on a project's board.",
973
1034
  entity: taskSchema,
974
1035
  projections: { card: cardSchema },
975
1036
  methods: { ...crud.contract({ entity: taskSchema, get: true }) },
976
1037
  collections: {
977
1038
  byProject: {
1039
+ describe: "A project's open tasks, in board order.",
978
1040
  // a column holding the scope value
979
1041
  scope: "projectId",
980
1042
  // the projection each item is sent as
@@ -991,7 +1053,12 @@ export const task = defineContract("taskService", {
991
1053
  views: { mine: (row, who) => row.assigneeId === who.userId },
992
1054
  },
993
1055
  // each user's own
994
- assigned: { scope: "assigneeId", item: "card", order: [["id", "asc"]] },
1056
+ assigned: {
1057
+ describe: "The tasks assigned to a user.",
1058
+ scope: "assigneeId",
1059
+ item: "card",
1060
+ order: [["id", "asc"]],
1061
+ },
995
1062
  },
996
1063
  });
997
1064
  ```
@@ -1134,18 +1201,36 @@ as a 4.x client instead of being refused with `PROTOCOL_MISMATCH`. The shim
1134
1201
  serves request/response calls only: `socket.emit("taskService:get", payload, ack)`
1135
1202
  runs through the 5.0 pipeline and is answered in the 4.x `ServiceResponse`
1136
1203
  shape, `{ success: true, data }` or `{ success: false, error, code }`, with
1137
- the HTTP status of the error code as `code`. 4.x subscriptions, collections
1138
- and channels are not served. Each service, method and principal kind that
1139
- calls through the shim is logged once at `warn`, so the remaining 4.x
1140
- clients can be found.
1204
+ the HTTP status of the error code as `code`. A call through the shim runs
1205
+ with the `ctx.socketId` and `ctx.rooms` of the socket it arrived on, so a
1206
+ handler can put a 4.x client in an app room; it leaves its rooms when it
1207
+ disconnects, and `onRoomLeave` and presence hear it as they hear a 5.0
1208
+ socket. 4.x subscriptions, collections and channels are not served, and a
1209
+ contract's events reach only 5.0 sockets: delivering events to 4.x
1210
+ listeners stays the app's own raw emit to the room until those clients
1211
+ speak protocol 5. Each service, method and principal kind that calls
1212
+ through the shim is logged once at `warn`, so the remaining 4.x clients can
1213
+ be found.
1214
+
1215
+ A service renamed during the migration keeps its old name for 4.x clients
1216
+ through aliases: with `legacyWire: { aliases: { taskService: "cardService" } }`,
1217
+ `socket.emit("taskService:get", payload, ack)` runs `cardService.get`, with
1218
+ that method's validation, access and `onCall` (grants are read under
1219
+ `cardService`, never under the old name). Its log line names the alias, once
1220
+ per old name, method and principal kind, so the app can see when the old name
1221
+ is no longer called. Each alias must name a registered service and must not be
1222
+ one itself, or `createServer` throws. Only 4.x events are aliased: a protocol-5,
1223
+ HTTP or MCP call to `taskService` finds no service.
1141
1224
 
1142
1225
  ### MCP bridge
1143
1226
 
1144
1227
  `@fitzzero/quickdraw-core/server/mcp` serves the services to AI agents as MCP
1145
1228
  tools generated from their contracts at startup: one tool per method, named
1146
- `{service}_{method}`, described by the method's `describe` text, with the
1229
+ `{service}_{method}`, described by the method's `describe` text (without
1230
+ one, by `service.method (kind)`, which tells an agent nothing), with the
1147
1231
  input schema's JSON Schema as its arguments and `readOnlyHint` on every
1148
- query. Every tool call goes through the dispatcher with transport `"mcp"`,
1232
+ query. The API docs print each method's default tool name and its read-only
1233
+ hint. Every tool call goes through the dispatcher with transport `"mcp"`,
1149
1234
  so input validation, access checks and limits apply exactly as on a socket.
1150
1235
  A method whose input cannot describe itself as JSON Schema stops the
1151
1236
  registry at startup, naming the method, unless it is excluded.
@@ -1210,6 +1295,68 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
1210
1295
  list with `exclude` or `include`, per registry, and serve agents of
1211
1296
  different reach from separate registries.
1212
1297
 
1298
+ `bind` fills chosen arguments from who is calling instead of from the agent,
1299
+ so an agent bound to one project cannot name another in its calls. Here an
1300
+ agent's token names its project as a verified claim (`principal.claims`):
1301
+
1302
+ <!-- example: apps/api/src/mcp.ts#bind -->
1303
+
1304
+ ```ts
1305
+ // agents bound to one project: an agent's token names it as a verified claim
1306
+ const projectAgents = createMcpRegistry({
1307
+ services: [taskService],
1308
+ dispatcher: server.dispatcher,
1309
+ // agent tokens only: anyone else is anonymous, and every tool here refuses them
1310
+ principal: (request) =>
1311
+ verifyAgentToken(request.transport === "http" ? request.token : process.env.AGENT_TOKEN),
1312
+ // filled from the claim on every call, and left out of every tool's arguments
1313
+ bind: { projectId: ({ principal }) => principal.claims?.projectId },
1314
+ include: ["taskService.create", "taskService.countOnBoard"],
1315
+ // the tools' public names: create_task and count_tasks
1316
+ name: (_service, method) => (method === "create" ? "create_task" : "count_tasks"),
1317
+ });
1318
+
1319
+ // beside the first registry, at a path of its own: GET /agents/tools, POST /agents/invoke
1320
+ app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
1321
+ ```
1322
+
1323
+ - Each bound argument is left out of the `inputSchema` of every method's tool
1324
+ whose object input has it: `create_task` lists `id` and `title`, and
1325
+ `count_tasks` takes no arguments. Every call of such a tool gets the
1326
+ binder's value before its input is validated, through `tools/call` and both
1327
+ shapes of `POST /mcp/invoke` alike.
1328
+ - A call fails closed, before the method runs: `UNAUTHENTICATED` for an
1329
+ anonymous caller, and `FORBIDDEN` when the binder returns `undefined` or
1330
+ `null`, or when the agent sent the argument with another value. A binder
1331
+ that throws fails the call with `INTERNAL`, or with the code of the
1332
+ `QuickdrawError` it threw.
1333
+ - `createMcpRegistry` throws at startup when a bound argument is in no
1334
+ selected method's object input (a misspelled name would leave the real
1335
+ argument to the agent), and when it is a property of an input that is not
1336
+ an object, which it names: leave that method out, or make its input an
1337
+ object.
1338
+ - Custom tools are never bound, and neither are the calls their `caller`
1339
+ makes: a custom tool reads `principal` itself. `describeTools` and the API
1340
+ docs list the inputs as the contracts declare them, with nothing bound.
1341
+ - Binding is not access control. The same principal reaches the method over a
1342
+ socket or HTTP with nothing bound, so the method's access must still
1343
+ confine it to its project.
1344
+
1345
+ Two more needs are met by the options above, with nothing added:
1346
+
1347
+ - **A tool set per kind of caller.** Make one registry per kind, each with its
1348
+ own `include`, `bind` and `principal`, and serve each at its own `path` or
1349
+ from its own stdio entry, as above. A registry's `principal` hook refuses
1350
+ the other kinds: throwing fails their calls with `UNAUTHENTICATED`.
1351
+ - **Frozen public tools.** `name` gives a method's tool any public name, as
1352
+ `create_task` above. When agents depend on tool names and argument shapes
1353
+ that must never change, give that surface a contract of its own: its
1354
+ methods' inputs and `describe` texts are the frozen shapes, its handlers
1355
+ call the app's services through `ctx.services`, and a registry with
1356
+ `name: (_service, method) => method` serves it. The bridge never reshapes a
1357
+ tool's arguments on the way in: such a tool would be invisible to the API
1358
+ docs and to lint, which read the contracts.
1359
+
1213
1360
  ## The client
1214
1361
 
1215
1362
  `createQuickdrawClient(contracts)` builds one typed client from the
@@ -1517,6 +1664,7 @@ const newTaskSchema = z.object({ projectId: z.string(), title: z.string() });
1517
1664
  const taskPatch = z.object({ title: z.string(), status: z.string() }).partial();
1518
1665
 
1519
1666
  export const task = defineContract("taskService", {
1667
+ describe: "Tasks on a project's board.",
1520
1668
  entity: taskSchema,
1521
1669
  projections: { card: cardSchema },
1522
1670
  methods: {
@@ -1534,7 +1682,11 @@ export const task = defineContract("taskService", {
1534
1682
  bulkUpdate: { input: taskPatch },
1535
1683
  bulkDelete: true,
1536
1684
  }),
1537
- archive: mutation({ input: z.object({ id: z.string() }), output: "entity" }),
1685
+ archive: mutation({
1686
+ input: z.object({ id: z.string() }),
1687
+ output: "entity",
1688
+ describe: "Archives a task, which leaves its board.",
1689
+ }),
1538
1690
  },
1539
1691
  });
1540
1692
  ```
@@ -1679,6 +1831,7 @@ import { defineContract, search } from "@fitzzero/quickdraw-core";
1679
1831
  import { cardSchema, taskSchema } from "../schemas";
1680
1832
 
1681
1833
  export const task = defineContract("taskService", {
1834
+ describe: "Tasks on a project's board.",
1682
1835
  entity: taskSchema,
1683
1836
  projections: { card: cardSchema },
1684
1837
  methods: {
@@ -1693,6 +1846,7 @@ export const task = defineContract("taskService", {
1693
1846
  },
1694
1847
  collections: {
1695
1848
  byProject: {
1849
+ describe: "A project's tasks, in board order.",
1696
1850
  scope: "projectId",
1697
1851
  item: "card",
1698
1852
  order: [
@@ -1818,6 +1972,7 @@ import { z } from "zod";
1818
1972
  const projectSchema = z.object({ id: z.string(), name: z.string() });
1819
1973
 
1820
1974
  export const project = defineContract("projectService", {
1975
+ describe: "Projects and the people they are shared with.",
1821
1976
  entity: projectSchema,
1822
1977
  methods: {
1823
1978
  // the JSON access list jsonAcl reads: share, unshare, setLevel, listShares
@@ -1831,6 +1986,7 @@ export const project = defineContract("projectService", {
1831
1986
  collections: {
1832
1987
  // each user's projects: an invite adds the project, a remove or a leave takes it out
1833
1988
  mine: {
1989
+ describe: "The projects a user is a member of.",
1834
1990
  scope: via({ model: "projectMember", entry: "projectId", scope: "userId" }),
1835
1991
  item: "entity",
1836
1992
  order: [["id", "asc"]],
@@ -1924,6 +2080,7 @@ import { admin, defineContract } from "@fitzzero/quickdraw-core";
1924
2080
  import { taskSchema } from "../schemas";
1925
2081
 
1926
2082
  export const task = defineContract("taskService", {
2083
+ describe: "Tasks on a project's board.",
1927
2084
  // Zod 4.2 or later: the fields come from its JSON Schema
1928
2085
  entity: taskSchema,
1929
2086
  methods: {
@@ -1948,6 +2105,8 @@ export const taskService = qd.defineService(task, {
1948
2105
  displayName: "Tasks",
1949
2106
  // never shown, returned or written
1950
2107
  hiddenFields: ["notes"],
2108
+ // the only fields adminCreate and adminUpdate write; the rest are read-only
2109
+ editable: ["title", "status"],
1951
2110
  fieldOverrides: { assigneeId: { type: "relation", relationService: "userService" } },
1952
2111
  }),
1953
2112
  },
@@ -2005,12 +2164,24 @@ export function AdminTasks() {
2005
2164
  `adminUpdate({ id, data })` write the entity's fields through the tracked
2006
2165
  client, so subscribers and collections get the same frames as for any
2007
2166
  other write; `adminDelete({ id })` returns `null`. `id`, `createdAt` and
2008
- `updatedAt` are never writable, nor are hidden fields or those an override
2009
- made read-only (`VALIDATION`); without a service-wide `Admin` grant, nor
2010
- are the columns the policy reads, and a row moves to another parent only
2011
- with the row level on it (`FORBIDDEN`, as for the read/write kit); each
2012
- value is checked by the entity schema itself, and a value the database
2013
- refuses is `VALIDATION`. A missing row is `NOT_FOUND`.
2167
+ `updatedAt` are never writable, nor are hidden fields, those an override
2168
+ made read-only or those `editable` leaves out (`VALIDATION`); without a
2169
+ service-wide `Admin` grant, nor are the columns the policy reads, and a
2170
+ row moves to another parent only with the row level on it (`FORBIDDEN`,
2171
+ as for the read/write kit); each value is checked by the entity schema
2172
+ itself, and a value the database refuses is `VALIDATION`. A missing row
2173
+ is `NOT_FOUND`.
2174
+ - `editable: ["title", "status"]` names the only fields `adminCreate` and
2175
+ `adminUpdate` write: `adminMeta` reports every other field
2176
+ `editable: false`, so a generic form leaves it read-only, and a write
2177
+ naming one is `VALIDATION` ("is not editable"). Each must be a field the
2178
+ kit shows, never `id` or a timestamp, and a `fieldOverrides` entry may
2179
+ not say otherwise. `admin.contract({ entity, editable })` takes the same
2180
+ list, so the writes' input checks, types and JSON Schema (and the MCP
2181
+ tools made from it) name only those fields; given to both halves, the
2182
+ two lists must name the same fields. Without either list the kit writes
2183
+ what it wrote in 5.0.0: every field it shows but `id` and the timestamps,
2184
+ less those an override made read-only.
2014
2185
  - `adminMeta()` returns `{ serviceName, displayName, fields }`, one
2015
2186
  `{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService?, kind?, showInForm? }`
2016
2187
  per field: `type` is
@@ -2093,28 +2264,48 @@ const cursorSchema = z.object({ projectId: z.string(), taskId: z.string(), x: z.
2093
2264
  const logLineSchema = z.object({ line: z.string() });
2094
2265
 
2095
2266
  export const task = defineContract("taskService", {
2267
+ describe: "Tasks on a project's board.",
2096
2268
  entity: taskSchema,
2097
2269
  methods: {
2098
- enterBoard: mutation({ input: z.object({ projectId: z.string() }), output: z.boolean() }),
2270
+ enterBoard: mutation({
2271
+ input: z.object({ projectId: z.string() }),
2272
+ output: z.boolean(),
2273
+ describe: "Joins the caller's socket to a project's board room.",
2274
+ }),
2099
2275
  },
2100
2276
  streams: {
2101
2277
  // one feed per task; a subscriber needs Read on the task, and first gets the latest 50 lines
2102
- logs: { item: logLineSchema, scope: "taskId", seed: 50, access: { entry: "Read" } },
2278
+ logs: {
2279
+ item: logLineSchema,
2280
+ scope: "taskId",
2281
+ seed: 50,
2282
+ access: { entry: "Read" },
2283
+ describe: "A task's log lines, as its job writes them.",
2284
+ },
2103
2285
  // one feed for everyone
2104
- load: { item: z.number(), volatile: true, access: "authenticated" },
2286
+ load: {
2287
+ item: z.number(),
2288
+ volatile: true,
2289
+ access: "authenticated",
2290
+ describe: "The server's load, sampled every second.",
2291
+ },
2105
2292
  },
2106
2293
  channels: {
2107
2294
  // 20 a second per socket; only from a socket in the board's room, which enterBoard joined
2108
2295
  cursor: {
2296
+ describe: "Where a user's cursor is on a task card.",
2109
2297
  payload: cursorSchema,
2110
2298
  ratePerSecond: 20,
2111
2299
  requires: { room: (cursor) => `board:${cursor.projectId}` },
2112
2300
  },
2113
2301
  },
2114
2302
  events: {
2115
- cursorMoved: { payload: cursorSchema },
2303
+ cursorMoved: { payload: cursorSchema, describe: "Another user's cursor moved." },
2116
2304
  // a user's last socket left a board: `onRoomLeave` sends it
2117
- leftBoard: { payload: z.object({ projectId: z.string(), userId: z.string() }) },
2305
+ leftBoard: {
2306
+ payload: z.object({ projectId: z.string(), userId: z.string() }),
2307
+ describe: "A user left a project's board.",
2308
+ },
2118
2309
  },
2119
2310
  });
2120
2311
  ```
@@ -2774,7 +2965,83 @@ for real, once per allowed principal: give inputs that can run again, or
2774
2965
  `MatrixCell` `{ name, principal }`, sync or async), called before each
2775
2966
  cell's call, that makes a row of its own (a task
2776
2967
  to delete, an unused name), so no cell depends on the order of the
2777
- principals.
2968
+ principals. Each cell of the report records its principal's `kind`, and a
2969
+ cell that differs is listed with it: on a service or method with `kinds`,
2970
+ give the matrix a principal of each kind it refuses.
2971
+
2972
+ ### Access snapshots
2973
+
2974
+ `snapshotAccessMatrix(app, { principals, reset, rows })` records the whole
2975
+ matrix instead of chosen cases: every method of the app's services (or of
2976
+ `services`), each entity subscribe (`qd:sub`) and each collection scope
2977
+ (`qd:col:sub`), as every principal and anonymously. The first run writes the
2978
+ outcomes to `__access__/<test file>.json` beside the test, and every later
2979
+ run compares its matrix with that file, so each changed cell is a decision
2980
+ someone made. Commit the file. Use it to pin a service's table, and before
2981
+ a migration, a policy refactor or a new kind of principal:
2982
+
2983
+ <!-- example: apps/api/src/services/task.test.ts#snapshot -->
2984
+
2985
+ ```ts
2986
+ it("pins who may call what, for every method, subscribe and scope", async () => {
2987
+ const app = await createTestApp({ services: [projectService, taskService], db });
2988
+ const report = await snapshotAccessMatrix(app, {
2989
+ principals: { owner: ada, member: bo, stranger: ed },
2990
+ reset: async () => {
2991
+ await resetDatabase(prisma); // at the start, and after each write access let through
2992
+ return await seed();
2993
+ },
2994
+ rows: (board) => ({ projectService: board.projectId, taskService: board.taskId }),
2995
+ });
2996
+ expect(report.inconclusive).toEqual([]); // every cell ran, with an input its schema accepts
2997
+ await app.close();
2998
+ });
2999
+ ```
3000
+
3001
+ - `reset()` empties the database and seeds it (`resetDatabase` from
3002
+ `./testing/prisma`, then the app's seed, with the untracked client), and
3003
+ resolves with what the seed made: the fixture. It runs first, and again
3004
+ after every mutation the pipeline did not refuse, so no cell sees the
3005
+ writes of another. `principals` may be a function of the fixture, for a
3006
+ seed that makes new ids; its names stay the same.
3007
+ - `rows(fixture)` names the row each service's cells are about: a row id,
3008
+ or named variants (`{ own: p1, other: p2 }`), each a row of the matrix of
3009
+ its own. It names every service an access form, an entity subscribe or a
3010
+ collection anchor of the matrix reads; a missing one is a `TypeError`
3011
+ before any cell runs. A `"self"` scope is subscribed with the caller's own
3012
+ user id.
3013
+ - Inputs are made from each method's input schema (Zod 4.2 or later
3014
+ describes itself as JSON Schema): the row goes where the access form reads
3015
+ it (`id`, the key an `entry` or `scope` form names, or where a function
3016
+ `id` finds it; for any other form, the input's `id`), and every other
3017
+ required value is as small as the schema allows. `inputs(ref, fixture)`
3018
+ gives a method's input instead (`ref` names the service, method, kind,
3019
+ variant, row and principal of the cell); `undefined` keeps the made one.
3020
+ A cell whose input fails the schema, or names no row where an `entry` or
3021
+ `scope` form reads it, is never called: it is `VALIDATION`, and the
3022
+ report lists it in `inconclusive`.
3023
+ - Each cell is the exact outcome: `"ok"`, or the error code
3024
+ (`UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`, ...). Each principal's
3025
+ `kind` is recorded beside the cells.
3026
+ - A cell whose outcome changed fails, naming the cell, both outcomes and
3027
+ whether the change opens or closes access (a changed `kind` fails too):
3028
+
3029
+ ```text
3030
+ snapshotAccessMatrix: 1 cell differs from the access snapshot (src/services/__access__/task.test.ts.json):
3031
+ taskService.rename as member: FORBIDDEN → ok (opens access)
3032
+ ```
3033
+
3034
+ When each change is meant, run the tests with
3035
+ `QD_UPDATE_ACCESS_SNAPSHOT=1` and commit the file.
3036
+
3037
+ - Cells added or removed (a new method, principal or exclusion) rewrite the
3038
+ file in a local run. Under CI (`CI=1` or `CI=true`) they fail, and so do a
3039
+ missing file and `QD_UPDATE_ACCESS_SNAPSHOT`: a snapshot that writes
3040
+ itself pins nothing.
3041
+ - Leave out a method whose outcome differs from run to run with
3042
+ `exclude: ["taskService.roll"]`; the file lists it. Take one snapshot per
3043
+ test file (one test file per service keeps merges small), or pass
3044
+ `{ file }`.
2778
3045
 
2779
3046
  ### Performance budgets
2780
3047
 
@@ -2856,6 +3123,7 @@ one format and names the method call it happened in:
2856
3123
  | `batch-create-many` | a `createMany` in an array-form `$transaction` could not report its rows |
2857
3124
  | `repeated-call` | one connection repeated a call or was refused `RATE_LIMITED` again and again (below) |
2858
3125
  | `tiered-field-in-output` | a method's own output schema declares a field the contract tiers, which only projection outputs strip; raised when the server is made (for an output without JSON Schema, when a reply carries it) |
3126
+ | `resolver-without-reads` | a service's policy is, or combines in `anyOf`, a `resolver` without `reads`, so no tracked write re-checks it; raised when the server is made |
2859
3127
 
2860
3128
  Updates and deletes by id inside an interactive transaction are not counted
2861
3129
  toward `n-plus-one`: that is how per-row writes are written (see tracked
@@ -2873,9 +3141,10 @@ fails: the call it happened in fails with `INTERNAL` and the error as its
2873
3141
  once the reply was recorded (over a socket or HTTP the reply was already
2874
3142
  sent, so that error is logged, not thrown). Strictness belongs to the app:
2875
3143
  warnings outside its calls (an ambient write while seeding, another app's
2876
- calls) are logged as usual, and `app.close()` ends it. The one raised when
2877
- the app is made, `tiered-field-in-output`, is thrown from `createTestApp`
2878
- itself, so a strict app over such a method does not start.
3144
+ calls) are logged as usual, and `app.close()` ends it. The ones raised when
3145
+ the app is made, `tiered-field-in-output` and `resolver-without-reads`, are
3146
+ thrown from `createTestApp` itself, so a strict app over such a method or
3147
+ policy does not start.
2879
3148
 
2880
3149
  A `repeated-call` warning names a client caught in a loop (a mutation fired
2881
3150
  from an effect that its own result runs again, a refetch that triggers
@@ -3061,10 +3330,13 @@ is also a server span named `service.method`, with an error status for
3061
3330
  ## API docs from contracts
3062
3331
 
3063
3332
  The `quickdraw-docs` command writes Markdown API docs from the contracts:
3064
- one page per service (its entity and field tiers, projections, methods
3065
- with their kind, input fields and output, collections, streams, channels
3066
- and events, from the schemas' JSON Schema where they have one) and a
3067
- `README.md` index. It reads contracts (and, with `--services`, the
3333
+ one page per service (its `describe`, its entity and field tiers,
3334
+ projections, methods with their `describe`, kind, default MCP tool name
3335
+ (`{service}_{method}`: a registry's `name`, `include` and `exclude` options
3336
+ are not visible to the command), the MCP read-only hint on a query, input
3337
+ fields and output, collections, streams, channels and events with their
3338
+ `describe`, from the schemas' JSON Schema where they have one) and a
3339
+ `README.md` index that lists each service's `describe`. It reads contracts (and, with `--services`, the
3068
3340
  services' definitions), never source code.
3069
3341
 
3070
3342
  ```bash
@@ -3080,9 +3352,18 @@ definitions: an "Access" section with the row policy, whether a
3080
3352
  service-wide `Admin` grant passes every check, who may watch the change
3081
3353
  topic (`watchAccess`) and the field levels; each method's access form, in
3082
3354
  words, and its `rowless`; who may open a collection's scope; a channel's
3083
- access; a stream's computed seed and when its items are checked. A
3084
- contract the services module has no service for says so, and a service
3085
- without a contract is an error. Pass the same flag to `--check`. The
3355
+ access; a stream's computed seed and when its items are checked. Each
3356
+ page also gets a "Depends on" section: the other services its
3357
+ declarations name, and which declaration names each one. A service depends
3358
+ on another when its row policy `inherit`s from it, it `writes` the model
3359
+ that service's rows live in, it `affects` that service's rows, one of its
3360
+ methods, channels or streams takes a `{ scope, of }` form of it, or one of
3361
+ its collections is anchored on that service's rows. A `writes` model that no
3362
+ service's rows live in is listed as a model. The index adds a Mermaid
3363
+ `graph LR` of the same edges, each labelled with how. Only declarations are
3364
+ read: a handler that reads another service's rows without declaring it adds
3365
+ nothing. A contract the services module has no service for says so, and a
3366
+ service without a contract is an error. Pass the same flag to `--check`. The
3086
3367
  services module is imported with everything it imports, as the server
3087
3368
  loads it: the workspace packages it uses (`@project/db`, `@project/shared`)
3088
3369
  load from their built output when their `package.json` points there, so
@@ -3098,7 +3379,19 @@ extensionless imports and `tsconfig` paths, which Node's loader refuses); a
3098
3379
  module whose own code throws runs once and its error is reported as is. Pages are only ever replaced
3099
3380
  or removed when they start with the generator's marker, and they are laid
3100
3381
  out as oxfmt and Prettier format Markdown, so formatting them changes
3101
- nothing. Run `--check` in CI next to the lint step.
3382
+ nothing.
3383
+
3384
+ Where the pages live depends on how busy the repository is:
3385
+
3386
+ - **Few pull requests at a time:** commit the pages and run `--check` in
3387
+ CI next to the lint step.
3388
+ - **Many at a time** (several agents, each changing methods): a committed
3389
+ generated file is a merge conflict in every pair of pull requests that
3390
+ touch contracts. Regenerate the pages on each push to the base branch
3391
+ instead, with the workflow the `quickdraw-api-docs` skill of
3392
+ [`@fitzzero/quickdraw-skills`](https://github.com/fitzzero/quickdraw/tree/main/packages/skills) ships (a job with a
3393
+ read-only token generates them, a second job commits them), and never
3394
+ commit them by hand.
3102
3395
 
3103
3396
  ## Lint rules and agent guidance
3104
3397
 
@@ -3106,7 +3399,8 @@ nothing. Run `--check` in CI next to the lint step.
3106
3399
  config every 5.0 app extends: it reports untracked and foreign writes, nested
3107
3400
  and raw SQL writes, hand-sent frames, inline auth guards, unbounded reads,
3108
3401
  database calls and emits in loops, layering breaks, bypasses of the typed
3109
- client, hand-written copies of kit methods (`prefer-kit`, a warning), and
3402
+ client, hand-written copies of kit methods (`prefer-kit`, a warning),
3403
+ contract members without a `describe` (`require-describe`, a warning), and
3110
3404
  every removed 4.x API with its replacement. Each rule supports a baseline,
3111
3405
  so an app can adopt it before fixing old code.
3112
3406
 
@@ -3160,7 +3454,7 @@ bunx @fitzzero/quickdraw-codemod v5 .
3160
3454
  | `./client` | `createQuickdrawClient`, `QuickdrawProvider`, `useQuickdraw`, `usePresence`, `useAdminServices`, `createQuickdrawConnection`, `call`, `callData`, `liveDataOf`, the coordinator; everything in `./utils` |
3161
3455
  | `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
3162
3456
  | `./parser` | the JSON-only Socket.IO parser |
3163
- | `./testing` | `createTestApp`, `describeAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
3457
+ | `./testing` | `createTestApp`, `describeAccessMatrix`, `snapshotAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
3164
3458
  | `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
3165
3459
  | `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
3166
3460
  | `./testing/prisma` | test databases on PostgreSQL or PGlite |