@owlmeans/planning 0.1.18-rc.28 → 0.1.18-rc.29

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/README.md CHANGED
@@ -9,7 +9,7 @@ the executor with `@owlmeans/server-planning` and reads remotely with `@owlmeans
9
9
  ## Installation
10
10
 
11
11
  ```sh
12
- bun add @owlmeans/planning@^0.1.18-rc.28 ajv ajv-formats
12
+ bun add @owlmeans/planning@^0.1.18-rc.29 ajv ajv-formats
13
13
  ```
14
14
 
15
15
  ## Concepts
@@ -251,7 +251,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
251
251
  your project's skill store (`.agents/skills/`):
252
252
 
253
253
  ```sh
254
- npx @owlmeans/agent-skills@^0.1.18-rc.54
254
+ npx @owlmeans/agent-skills@^0.1.18-rc.57
255
255
  ```
256
256
 
257
257
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/planning",
4
- "version": "0.1.18-rc.28",
5
- "generatedAt": "2026-10-16T18:05:19.005Z",
4
+ "version": "0.1.18-rc.29",
5
+ "generatedAt": "2026-10-20T23:19:58.625Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -10,7 +10,129 @@
10
10
  "name": "planning",
11
11
  "category": "package-specific",
12
12
  "file": "skills/planning/SKILL.md",
13
- "canonicalPath": ".agents/skills/planning/SKILL.md"
13
+ "canonicalPath": ".agents/skills/planning/SKILL.md",
14
+ "parts": [
15
+ {
16
+ "name": "collaboration",
17
+ "file": "skills/planning/parts/collaboration.md",
18
+ "description": "Assignees, comments with stable mentions, teams across projects, card hierarchy rules, the resources protocol branches, and PlanningReply's organization-free records.",
19
+ "purposes": [
20
+ "detail",
21
+ "view"
22
+ ],
23
+ "mentions": [
24
+ "Assignee",
25
+ "Team",
26
+ "Comment",
27
+ "CommentMention",
28
+ "mentionHelper",
29
+ "PlanningResourceKind",
30
+ "PLANNING_PROJECT_TEAM",
31
+ "planningResourceAliases",
32
+ "planningReplyHelper",
33
+ "PlanningReply",
34
+ "teams.addMember",
35
+ "reporter",
36
+ "assignee"
37
+ ]
38
+ },
39
+ {
40
+ "name": "errors",
41
+ "file": "skills/planning/parts/errors.md",
42
+ "description": "Every refusal class with its planning marker and HTTP status, the httpStatus rules for a new refusal, and the eight-language i18n the package registers.",
43
+ "purposes": [
44
+ "feedback"
45
+ ],
46
+ "mentions": [
47
+ "PlanningError",
48
+ "WorkcardNotFound",
49
+ "IllegalTransition",
50
+ "WorkcardConflict",
51
+ "PlanningRefused",
52
+ "FieldsInvalid",
53
+ "SchemaConflict",
54
+ "SchemaSealed",
55
+ "SchemaInvalid",
56
+ "PlanningForbidden",
57
+ "CommitTimeout",
58
+ "CommitFailed",
59
+ "lib:planning"
60
+ ]
61
+ },
62
+ {
63
+ "name": "fold",
64
+ "file": "skills/planning/parts/fold.md",
65
+ "description": "The pure fold (applyTransition, applyRelationship), what computeChanges records and assertMutable refuses, and the one query translation with its wire form.",
66
+ "purposes": [
67
+ "query",
68
+ "state"
69
+ ],
70
+ "mentions": [
71
+ "applyHelper",
72
+ "applyTransition",
73
+ "applyRelationship",
74
+ "changesHelper",
75
+ "computeChanges",
76
+ "assertMutable",
77
+ "isEmptyChange",
78
+ "queryHelper",
79
+ "criteriaOf",
80
+ "listOptionsOf",
81
+ "summaryOf",
82
+ "wireHelper",
83
+ "encodeWorkcardQuery",
84
+ "decodeWorkcardQuery",
85
+ "WorkcardQuery"
86
+ ]
87
+ },
88
+ {
89
+ "name": "mounting",
90
+ "file": "skills/planning/parts/mounting.md",
91
+ "description": "The protocol tree makePlanningProtocols declares (leaves, routes, aliases, guards, the commit socket) and how a generated target mounts it.",
92
+ "purposes": [
93
+ "server-api",
94
+ "config"
95
+ ],
96
+ "mentions": [
97
+ "makePlanningProtocols",
98
+ "planningAliasHelper",
99
+ "planningAliases",
100
+ "planningDefinitionAliases",
101
+ "PLANNING_PATH",
102
+ "socketBase",
103
+ "ExecuteRequest",
104
+ "CommitQuery",
105
+ "CommitFeedQuery",
106
+ "schema.define"
107
+ ]
108
+ },
109
+ {
110
+ "name": "types",
111
+ "file": "skills/planning/parts/types.md",
112
+ "description": "Declaring card, project and specification types and status flows as closed data, the data-defined layers (code, entity, project) and retirement, and the rules every record schema keeps.",
113
+ "purposes": [
114
+ "data"
115
+ ],
116
+ "mentions": [
117
+ "WorkcardTypeSchema",
118
+ "ProjectTypeSchema",
119
+ "StatusFlowSchema",
120
+ "SpecificationSlot",
121
+ "CodePolicy",
122
+ "makeSchemaRegistry",
123
+ "scopedSchemaHelper",
124
+ "resolveScopedBundle",
125
+ "ScopedSchemaRecord",
126
+ "ScopedSchemaBundle",
127
+ "SchemaStore",
128
+ "putType",
129
+ "putFlow",
130
+ "putAssigneeType",
131
+ "scopedCardTypes",
132
+ "overridable"
133
+ ]
134
+ }
135
+ ]
14
136
  }
15
137
  ]
16
138
  }
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/planning
9
9
 
10
10
  **Layer:** Cross-cutting domain
11
- **Install:** `"@owlmeans/planning": "^0.1.18-rc.28"` in `dependencies` (`ajv` and `ajv-formats` are peers)
11
+ **Install:** `"@owlmeans/planning": "^0.1.18-rc.29"` in `dependencies` (`ajv` and `ajv-formats` are peers)
12
12
 
13
13
  The contracts of project planning: record shapes, schemas, refusals, the protocol tree, the pure
14
14
  fold and the models. No database, no fastify, no React. The executor, the plugin registry, the
@@ -114,192 +114,6 @@ forms generated code keeps writing are `server-planning` → Creating and readin
114
114
  advanced flow — closed only when every flow is closed. A status a flow does not declare reads as
115
115
  planned.
116
116
 
117
- ## Declaring a type
118
-
119
- A `WorkcardTypeSchema` / `ProjectTypeSchema` is plain data registered through a plugin's `schemas`
120
- or `makeSchemaRegistry({ types, flows })`. **Every declaration schema is closed** — these are ALL
121
- the keys:
122
-
123
- | Declaration | Required | Optional |
124
- |---|---|---|
125
- | card / specification type | `type` (its key), `kind`, `version`, `fields`, `flows` (at least one), `specifications` | `intrinsic`, `relationships`, `labels`, `code`, `label`, `tone`, `icon`, `overridable` |
126
- | project type | the same, plus `cardTypes` | `projectTypes`, `scopedCardTypes` |
127
- | flow | `id` (its key), `version`, `statuses` (at least one), `transitions` | `label`, `tone`, `icon`, `overridable` |
128
- | status | `key`, `intrinsic` | `initial`, `terminal`, `label`, `tone` |
129
- | transition rule | `name`, `from` (status keys or `'*'`), `to` | `label`, `explicit` |
130
-
131
- The display name is `label` everywhere; a type or a flow has no `name`, `key` or `description`
132
- (`key` belongs to a status, `name` to a rule). As data, `definitions` refuses an extra key with
133
- `SchemaInvalid`; in code the registry does not validate a declaration, so only TypeScript's
134
- excess-property check catches it — keep the literal typed.
135
-
136
- - `fields` — the AJV schema of `fields`; the registry compiles and caches it (`validator(type)`).
137
- - `specifications` — the slots this type's cards carry as children. A slot may name its
138
- specification `type`; otherwise `specificationHelper.specificationTypeOf` takes the only registered specification
139
- type, then the one sharing the parent type's prefix (`viable:project` → `viable:spec`).
140
- - `code` — `CodePolicy { prefix?, style: random|sequential|slug, length?, uppercase?, uniqueWithin:
141
- parent|entity, mutable? }`. A code is fixed once minted unless `mutable`.
142
- - `relationships` — the edge types a card may start (`single` = at most one per card).
143
- - `labels` — the allowed labels (any when omitted).
144
- - A project type lists `cardTypes` (and `projectTypes` for nesting) its children may have;
145
- `scopedCardTypes: true` also admits card types defined as data (below).
146
- - `overridable: true` on a code type or flow opens it to a data-defined override.
147
- - `tone` / `icon` (type, flow) and `tone` (status) — PRESENTATION hints the package stores as free
148
- text of at most 64 characters, neither validating nor rendering them. Their standard values and
149
- their standard editor and renderer are `@owlmeans/web-panel/picker`: `tone` a palette key
150
- (`PICKER_COLOR_KEYS` — red … graphite, 24) or a lowercase `#rrggbb` when the editor allows custom
151
- colours, `icon` a kebab-case lucide name of its catalogue (`PICKER_ICON_NAMES`). A person edits a
152
- type's or a flow's mark with `IconColorPicker` and shows it with `IconTile` / `ColorDot`; a
153
- status's `tone` usually names a meaning the application's own palette maps (`ColorPicker` with
154
- `colors`). An unknown value renders the picker's fallback (`slate`, `circle`); server code and
155
- seeds read the data React-free from `@owlmeans/web-panel/picker/vocabulary`.
156
-
157
- ## Data-defined types and flows
158
-
159
- Card types, assignee types and status flows may also be DATA — `ScopedSchemaRecord`s a store keeps
160
- behind the optional `PlanningStore.schemas` port. Card types and flows layer **code → entity → project**;
161
- assignee types layer **code → entity** and refuse a project-local declaration:
162
-
163
- - `code` is the plugin registry (the default), `entity` an organization-wide record
164
- (`project` absent), `project` a record of one project card. A project record overrides an
165
- organization one of the same key.
166
- - **Card types, assignee types and flows are data.** A code key is sealed unless its declaration says
167
- `overridable: true`; project and specification types are always code's (`scopedSchemaHelper.assertOverridable` →
168
- `SchemaSealed`).
169
- - A record's `version` is its compare-and-set token and is written into the declaration's own
170
- `version`: a write lands only at its layer's next version, 1 for a new key (`SchemaConflict`).
171
- - The closed-form checks are shared by every store: `scopedSchemaHelper.assertFlowSchema` (the declaration's schema,
172
- unique status keys, every `to` declared, `from` `'*'` or declared statuses) and
173
- `scopedSchemaHelper.assertTypeSchema` (the `card` kind, a compiling `fields` schema, unique flows that resolve) —
174
- `SchemaInvalid`.
175
- - **Retired** means offered for nothing new, still resolved for what uses it: a retired record
176
- gives way to a live declaration below it, and a key retired in every layer resolves retired —
177
- present in the bundle's `types`/`flows`, listed in `retired`. A flow cannot be retired while a
178
- live type of an affected layer still runs it (`scopedSchemaHelper.flowInUse` → `SchemaInUse`).
179
- - `scopedSchemaHelper.resolveScopedBundle(code, records, scope)` is the ONE resolution (pure);
180
- `scopedRegistryOf` is its read-only registry (`originOf`, `isRetired`; `register*`/`load` refuse). A
181
- `ScopedSchemaBundle` extends the plain bundle with `scope`, `revision`, `origins`, `retired`, so a
182
- reader of the plain bundle is unaffected.
183
- - `SchemaStore`: `list(where)`, `put(record)` (the CAS), `purge({ entityId, project })`,
184
- `revision(entityId)` (monotonic per organization), optional `watch(listener)`.
185
- - `PlanningFacade.definitions` (present only where the store has the port): `bundle(project?)`,
186
- `registry(project?)`, `records`, `putType`/`putFlow`/`putAssigneeType` (CAS on the declaration's version),
187
- `define` (each at its layer's next version, flows first), `seed` (only the keys the layer lacks),
188
- `retire(kind, key, { project? })`. The declaration still carries its own `version` (any number
189
- ≥ 1 for `define`/`seed`, which assign the layer's next one; the exact next one for `put*`), and
190
- a project layer is `{ project: <project card id> }` in the second argument (a `define` call:
191
- `server-planning` → Creating and reading a card).
192
-
193
- ## The fold
194
-
195
- `applyHelper.applyTransition(card | undefined, transition)` is pure and total:
196
-
197
- - an already-applied seq (`seq <= card.seq`) returns the same card — re-folding is safe;
198
- - anything but `card.seq + 1` (or seq 1 for a create with no card) throws
199
- `PlanningError('fold:out-of-order')`;
200
- - `create` builds the whole record from `changes` + `id`/`kind`/`type`/`entityId`/`createdAt`/`seq`;
201
- - `update`/`transit` write `changes` — **`fields` and `flows` merge shallowly, every other key
202
- replaces** — and clear `unset` (`key`, `fields.key`, `flows.id`); identity and required keys are
203
- never written or cleared;
204
- - `link`/`unlink` move only `seq`/`head`/`updatedAt`; `delete` answers `null`;
205
- - every result carries `seq = transition.seq`, `head = max(head, seq)`, `updatedAt = transition.at`;
206
- - the log is replayed AS WRITTEN: a stored row admission would refuse (an update naming
207
- `createdBy`) still folds. Guards live at admission (`changesHelper.assertMutable`, the executor), never in the
208
- fold, so a stored log never stops projecting.
209
-
210
- `applyHelper.applyRelationship(links, transition)` folds the edges: a create's `links`, `link`, `unlink`, and a
211
- `delete` dropping every edge touching the card. An existing edge is kept, so re-folding adds nothing.
212
-
213
- `changesHelper.computeChanges(card, exec, type, registry, at, { slot? })` is what the executor records:
214
-
215
- - create — the whole initial record: normalized `parents`, every flow at its initial status (the
216
- draft's `status` for the primary), mirrored `status`/`intrinsic`, `closedAt` when it starts
217
- closed; a specification gets its format, `bodyChars` and revision 1 when the slot is revisioned.
218
- The code must already be on the draft (the executor mints first);
219
- - update — only differing values; `null` or `exec.unset` clears a field that has one; a moved
220
- `parent` replaces the old one in `parents`; changed content of a revisioned document sets
221
- `revision + 1`. An update that changes nothing is `changesHelper.isEmptyChange` and must append nothing;
222
- - transit — `flows[flow] = rule.to`, `status` on the primary flow, `intrinsic` when it moves,
223
- `closedAt` set on entering closed and cleared on leaving; throws `IllegalTransition`;
224
- - link/unlink/delete — nothing.
225
-
226
- `changesHelper.assertMutable(exec, type)` refuses (`planning:immutable:<field>`) identity keys (`createdAt`
227
- among them), `createdBy` in `changes` or `unset` on any action (a create included),
228
- `revision` / `bodyChars`, `status`/`intrinsic`/`flows`/`closedAt` outside a create (they move only
229
- through `transit`), `category`, a fixed `code`, and clearing a required field.
230
-
231
- ## Querying
232
-
233
- `queryHelper.criteriaOf(query, scope)` is the ONE translation from `WorkcardQuery` to `Criteria<Workcard>`:
234
- bare values and arrays pass through (`kind`, `type`, `status`, `intrinsic`, `code`, `category`),
235
- `parent` is direct children, `within` → `parents: { $contains }`, `labels` → `$overlaps`, `ids` →
236
- `id: { $in }`, `flow` → `flows.<id>`, `fields` → `fields.<key>` equality, `q` → `$or` of title /
237
- description `$ilike` (wildcards escaped) and code `$startsWith`, `updatedSince` → `updatedAt: {
238
- $gte }`. `entityId` always comes from the scope; `undefined` is omitted. Paging and sort are
239
- `queryHelper.listOptionsOf` (`size: 0` = no limit). `queryHelper.summaryOf(cards, parents)` counts DIRECT children by
240
- intrinsic state and gives no key to a parent with none. A scope naming `projects` is narrowed by
241
- the SERVER facade on top of this translation (`server-planning` → The access resolver);
242
- `TransitionWhere.project` and `RelationshipWhere.project` take one id or a list.
243
-
244
- **A query crosses HTTP in its wire form.** The OwlMeans transport cannot carry arrays or nested
245
- objects in a query string (the client writes `key[]=`, the server reads that key literally), so
246
- the list, summary, transitions, specifications and links protocols take `*QueryWire` shapes: lists
247
- comma-joined (a JSON array when an entry holds a comma), objects as JSON, sort as `field,-field`.
248
- A client calls `wireHelper.encodeWorkcardQuery(query)` before `call({ query })`; a handler calls
249
- `wireHelper.decodeWorkcardQuery(req.query)` — tolerant of arrays and of the rich form — and then
250
- `queryHelper.criteriaOf`. A value that does not decode throws `PlanningError('malformed:query:<key>')`.
251
-
252
- ## The protocol tree and mounting
253
-
254
- `makePlanningProtocols({ base: { alias, path?, parent?, service? }, guards, gate?, socketBase?, definitions? })`:
255
-
256
- | Leaf | Route | Request → reply |
257
- |---|---|---|
258
- | `schema.list` | GET `/schemas` | → `PlanningSchemaBundle` |
259
- | `card.list` | GET `/cards` | query `WorkcardQueryWire` → `ListResult<Workcard>` |
260
- | `card.summary` | GET `/cards/summary` | query `SummaryQueryWire` → `SummaryView` |
261
- | `card.get` | GET `/cards/:id` | → `Workcard` |
262
- | `card.transitions` | GET `/cards/:id/transitions` | query `TransitionQueryWire` |
263
- | `card.specifications` | GET `/cards/:id/specifications` | query `SpecificationQueryWire` |
264
- | `spec.get` / `spec.revisions` | GET `/specifications/:id` / `…/revisions` | query `RevisionsQuery` |
265
- | `link.list` | GET `/links` | query `RelationshipQueryWire` |
266
- | `transition.get` | GET `/transitions/:transition` | → `Transition` |
267
- | `execute` | POST `/execute` | body `ExecuteRequest` → `TransitionReceiptView` |
268
- | `commit.get` | GET `/commits/:transition` | query `CommitQuery { wait? }` → `CommitStatus` |
269
- | `commit.events` | SOCKET `/commits` | query `CommitFeedQuery` → `CommitEvent` frames |
270
- | `schema.define` (with `definitions: true`) | POST `/schemas` | body `SchemaDefineRequest { project?, mode?, types?, flows?, retire? }` → `SchemaDefineReply { records, bundle }` |
271
-
272
- - Aliases derive from the base alias (`planningAliasHelper.planningAliases(base)` → `<base>:card:list`, …); two mounts
273
- are two base aliases. `definitions: true` adds `schema.define` (its alias from
274
- `planningAliasHelper.planningDefinitionAliases(base)`, kept apart so a tree without it names no undeclared alias)
275
- and gives `schema.list` a `SchemaListQuery { project? }` answered with that layer's scoped bundle.
276
- Without it the tree declares exactly the other leaves.
277
- - The guards and the gate sit on the base alone; every HTTP leaf inherits them. `path` defaults to
278
- `/planning`.
279
- - Static `/cards/summary` is declared before parametric `/cards/:id`.
280
- - The commit socket hangs under `socketBase` when given and then inherits THAT base's guards —
281
- pass an update/socket base that authenticates; otherwise it hangs under the planning base.
282
- - `actor` in an execute body is ignored on the wire: the server fills it from the request.
283
- - Responses carry no runtime schema (typed only), so a serializer never strips `fields`.
284
-
285
- ## Mounting in a target
286
-
287
- A generated target serves this tree as it is — no hand-written card, transition, schema or document
288
- routes; domain rules are `before` plugins. This section is the hub; each package skill has its half.
289
-
290
- - **common** declares the one tree the api and the web share: `makePlanningProtocols({ base: {
291
- alias: 'api:planning' }, guards: DEFAULT_GUARD, definitions: true, resources: true })`, in its own module beside
292
- the `PLANNING` plugin (`sources/common/src/planning.ts`).
293
- - **api** — `server-planning` → Mounting in a target (stock handlers, no commit socket, the
294
- resolver answering `writes`). **web** — `client-planning` → Mounting in a target. **store** —
295
- `planning-postgres`.
296
- - **Kit literals**: card types and flows are LITERAL objects in `PLANNING.schemas.flows` / `.types`,
297
- above the `// owlmeans: add planning flows|types above this line` sentinels — a spread or an
298
- imported constant hides them from the library's registration checks. Card types and flows carry
299
- `overridable: true` with open `fields` (`additionalProperties: true`); the container project type
300
- (always sealed) carries `scopedCardTypes: true`, so the app's people override and extend them as
301
- data through `schema.define`.
302
-
303
117
  ## Models
304
118
 
305
119
  `makeWorkcardModel(record, facade)` (and `makeProjectModel`, `makeSpecificationModel`, `modelOf`
@@ -317,120 +131,27 @@ by kind) is the same object on a server and in a browser. `schema`, `flow`, `sta
317
131
  `purge` (a delete of the project — the store removes everything under it).
318
132
  - `SpecificationModel` adds `body`, `revise`, `history` (from the log).
319
133
 
320
- ## Errors
321
-
322
- Every refusal is a registered `ResilientError` whose message is `planning:<marker>:<detail>` —
323
- the marker survives a hop where the class is unknown, so match on it there:
324
-
325
- | Class | Marker | HTTP |
326
- |---|---|---|
327
- | `PlanningError` | `planning:` (`fold:out-of-order`, `immutable:<field>`, `malformed:<what>`) | 500 |
328
- | `WorkcardNotFound` | `workcard-not-found:` — also the answer for another entity's card | 404 |
329
- | `UnknownWorkcardType` / `UnknownStatusFlow` | `unknown-type:` / `unknown-flow:` | 422 |
330
- | `ParentNotFound` / `CardTypeNotAllowed` | `parent-not-found:` / `card-type-not-allowed:` | 422 |
331
- | `PlanningRefused` | `refused:` — a plugin middleware said no | 422 |
332
- | `IllegalTransition` | `illegal-transition:<flow>:<transition>:<from>` | 409 |
333
- | `FieldsInvalid` / `LabelNotAllowed` | `fields-invalid:` / `label-not-allowed:` | 422 |
334
- | `SpecificationSlotUnknown` / `SpecificationRevisionConflict` | `specification-slot-unknown:` / `specification-revision-conflict:` | 422 / 409 |
335
- | `RelationshipRefused` / `CodeTaken` | `relationship-refused:` / `code-taken:` | 422 / 409 |
336
- | `WorkcardConflict` | `workcard-conflict:` — stale `expectSeq` | 409 |
337
- | `SchemaConflict` / `SchemaInUse` | `schema-conflict:` (lost version CAS) / `schema-in-use:` (retiring a flow a live type runs) | 409 |
338
- | `SchemaSealed` / `SchemaInvalid` | `schema-sealed:` / `schema-invalid:` | 422 |
339
- | `PlanningForbidden` | `forbidden:<grant>:<target>` — the request's access lacks a grant; `forbidden:writes:<id>` — the card or project is outside its `writes` | 403 |
340
- | `CommitTimeout` / `CommitFailed` | `commit-timeout:` (still pending, nothing undone) / `commit-failed:` | 500 |
341
- | `PlanningScopeMismatch` / `PlanningUnsupported` | `scope-mismatch:` / `unsupported:` | 404 / 500 |
342
-
343
- Type names are `PlanningError<Suffix>` (`PlanningErrorWorkcardNotFound`).
344
-
345
- **The HTTP column is each class's `static httpStatus`** (the `error` skill's principle; 500 = none
346
- declared): an addressed card that is absent or another entity's is 404, a write the request's
347
- access does not grant is 403, a card whose state or head (or a schema record whose version)
348
- refuses the write is 409, a body naming what the registry, the parent or a schema refuses is 422.
349
- Faults declare nothing, and so does `PlanningError` itself — its three causes share one class, and
350
- a base's static would reach every subclass. A new refusal declares on its own leaf class, and
351
- `tests/error-status.spec.ts` refuses an exported refusal whose status was not decided. A plugin's
352
- plain `throw` inside `before` becomes `PlanningRefused` and answers 422, so a plugin FAULT must
353
- throw a `ResilientError` that declares nothing.
354
-
355
- ## i18n
356
-
357
- Importing the package registers, in eight languages (en, pl, ru, be, uk, es, de, fr), `lib:planning.*` labels (`kind.*`,
358
- `intrinsic.*`, `action.*`, `commit.*`, `format.*`) and one text per refusal type under the shared
359
- `errors` resource (`errors.<TypeName>`), where a panel's error lookup finds it.
360
-
361
- ## Schema rules
362
-
363
- A record schema may become a Mongo `$jsonSchema`, so every exported schema keeps two rules: **no
364
- `integer`** (`number` only) and **every optional property `nullable: true`** (an enum then admits
365
- `null` too). No `oneOf`/`anyOf` inside a record schema. Build type arrays per schema, never share
366
- one object between schemas — ajv may extend a `type` array while compiling. The walk that pins this
367
- is part of the package's own tests.
368
-
369
134
  ## Depends On
370
135
 
371
136
  - `@owlmeans/resource` — `Criteria`, `ListOptions`, `ListResult`, `createListSchema`
372
137
  - `@owlmeans/entrypoint`, `@owlmeans/route` — the protocol tree
373
138
  - `@owlmeans/error`, `@owlmeans/i18n`, `@owlmeans/auth` (`IdValueSchema`), `@owlmeans/basic-ids`, `@owlmeans/context`
374
139
 
375
- ## Assignees, comments, teams and hierarchy
376
-
377
- Cards retain the transition log; auxiliary records use separate versioned resources. `Assignee`,
378
- `Team`, `Comment` and `CommentMention` have organization scope and `version` compare-and-set.
379
- `PlanningResourceKind` qualifies relationship endpoints so ids from different resources cannot
380
- collide. Omitted endpoint kinds mean `workcard` for stored legacy edges.
381
-
382
- Assignees have stable ids, normalized organization-unique nicknames (`mentionHelper.nicknameKey`),
383
- `kind: human | non-human`, `type`, flexible `fields`, optional `authentication: { provider,
384
- externalId }` and retirement. Human types require authentication by default; declare
385
- `authentication: 'optional'` to allow a human without it. Non-human types default to optional.
386
- Authentication is provider-neutral; `@owlmeans/planning-auth` adds the optional OwlMeans adapter.
387
- Code plugins declare `schemas.assigneeTypes`; data definitions use `putAssigneeType` or
388
- `define({ assigneeTypes })`. Assignee schemas are organization-wide, never project-local.
389
-
390
- `reporter` and `assignee` are optional top-level single assignee ids. Additional schema fields use
391
- `relationships: [{ name, field, toKind: PlanningResourceKind.Assignee, multiple?, to?, inverse? }]`.
392
- `to` restricts target assignee types. The field is authoritative; the store maintains its canonical
393
- edge and the facade exposes named inverse views. Do not mirror base fields in `fields`, invent
394
- join collections or manually write these derived edges. Retired assignees retain existing links
395
- and comments but cannot receive new assignments or team memberships.
396
-
397
- Comments belong to a card and a trusted author assignee. Encode stable mentions with
398
- `mentionHelper.encode(id, nickname)`, producing `[@nickname](assignee:<encoded-id>)`; renaming a
399
- nickname never retargets a mention. `CommentMention` is a separate derived cache with the source
400
- comment `revision`; reads repair interrupted cache updates. Never accept author ids from an
401
- untrusted request or parse ordinary prose as identity.
402
-
403
- Teams are organization resources, reusable across projects. `teams.addMember/removeMember` and
404
- `teams.attach/detach` use the existing relationship store, with canonical team-member and
405
- project-team edges. `teams.members(team)` and `teams.projects(team)` read membership and
406
- attachment; `teams.assignees(project)` reads effective assignees. Read project teams through
407
- `relationships.list({ from: project, type: PLANNING_PROJECT_TEAM, toKind: PlanningResourceKind.Team })`;
408
- project assignees are the deduplicated union of attached teams. Team `externalId` may name an
409
- external permission group; links do not mutate authentication group membership.
410
-
411
- Hierarchy uses existing `parent` and direct `parents`, with optional derived `parentType`.
412
- Declare compatible `parents: { types?, required? }` and `children: { types }` on card schemas.
413
- Required primary parents cannot be cleared; safe reparenting is supported. Cycles, cross-organization
414
- links, incompatible descendants and deleting an ordinary card with children are refused. The
415
- nearest primary project selects schemas; all project ancestors participate in access checks.
416
- Project `mode` is `opened` (default) or `closed`, separate from flow status; host policy may use it
417
- for access decisions.
418
-
419
- Declare `resources: true` on `makePlanningProtocols` to add assignee/team/comment/mention
420
- `get`, `list`, `write` branches and use `planningResourceAliases`. Enable `definitions: true`
421
- for assignee type schema writes. Use stock facade methods, handlers, stores and feeds on both
422
- sides; resource commands and versions are validated by the shared wire contract.
423
-
424
- Protocol responses use `PlanningReply<T>`: storage organization metadata (`entityId`) is absent
425
- from records, schema scopes, receipts and commit frames. `planningReplyHelper.project` handles
426
- only known record/envelope positions; custom fields, transition changes and schema definitions
427
- remain opaque. Native facades retain their trusted storage scope. Remote facades hydrate the
428
- shared record shape with empty advisory metadata; never derive organization identity from it.
429
-
430
-
431
140
  ## Related
432
141
 
433
142
  - [[server-planning]] — the executor, the plugin registry, the memory store, the handlers
434
143
  - [[client-planning]] — the remote facade, the state mirror, waiting for a commit
435
144
  - [[resource]] — the criteria language `queryHelper.criteriaOf` targets
436
145
  - [[planning-postgres]] — a durable store implementing every port, the schema port included
146
+
147
+ ## Parts
148
+
149
+ Load a part on top of this core when its job is in play.
150
+
151
+ | Part | Load it when |
152
+ |---|---|
153
+ | [types](parts/types.md) | Declaring a type or a status flow, data-defined types and flows (`scopedSchemaHelper`), or a new record schema |
154
+ | [fold](parts/fold.md) | Folding transitions (`applyHelper`, `changesHelper`) or translating a `WorkcardQuery` (`queryHelper`, `wireHelper`) |
155
+ | [mounting](parts/mounting.md) | Declaring or mounting the protocol tree (`makePlanningProtocols`), or serving it in a generated target |
156
+ | [errors](parts/errors.md) | Throwing, matching or phrasing a planning refusal (marker, HTTP status, i18n text) |
157
+ | [collaboration](parts/collaboration.md) | Assignees, reporters, comments and mentions, teams, card hierarchy, the `resources` branches or `PlanningReply` |
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: collaboration
3
+ description: Assignees, comments with stable mentions, teams across projects, card hierarchy rules, the resources protocol branches, and PlanningReply's organization-free records.
4
+ metadata:
5
+ purposes: detail, view
6
+ mentions: Assignee, Team, Comment, CommentMention, mentionHelper, PlanningResourceKind, PLANNING_PROJECT_TEAM, planningResourceAliases, planningReplyHelper, PlanningReply, teams.addMember, reporter, assignee
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/planning — Assignees, comments, teams and hierarchy
11
+
12
+ ## Assignees, comments, teams and hierarchy
13
+
14
+ Cards retain the transition log; auxiliary records use separate versioned resources. `Assignee`,
15
+ `Team`, `Comment` and `CommentMention` have organization scope and `version` compare-and-set.
16
+ `PlanningResourceKind` qualifies relationship endpoints so ids from different resources cannot
17
+ collide. Omitted endpoint kinds mean `workcard` for stored legacy edges.
18
+
19
+ Assignees have stable ids, normalized organization-unique nicknames (`mentionHelper.nicknameKey`),
20
+ `kind: human | non-human`, `type`, flexible `fields`, optional `authentication: { provider,
21
+ externalId }` and retirement. Human types require authentication by default; declare
22
+ `authentication: 'optional'` to allow a human without it. Non-human types default to optional.
23
+ Authentication is provider-neutral; `@owlmeans/planning-auth` adds the optional OwlMeans adapter.
24
+ Code plugins declare `schemas.assigneeTypes`; data definitions use `putAssigneeType` or
25
+ `define({ assigneeTypes })`. Assignee schemas are organization-wide, never project-local.
26
+
27
+ `reporter` and `assignee` are optional top-level single assignee ids. Additional schema fields use
28
+ `relationships: [{ name, field, toKind: PlanningResourceKind.Assignee, multiple?, to?, inverse? }]`.
29
+ `to` restricts target assignee types. The field is authoritative; the store maintains its canonical
30
+ edge and the facade exposes named inverse views. Do not mirror base fields in `fields`, invent
31
+ join collections or manually write these derived edges. Retired assignees retain existing links
32
+ and comments but cannot receive new assignments or team memberships.
33
+
34
+ Comments belong to a card and a trusted author assignee. Encode stable mentions with
35
+ `mentionHelper.encode(id, nickname)`, producing `[@nickname](assignee:<encoded-id>)`; renaming a
36
+ nickname never retargets a mention. `CommentMention` is a separate derived cache with the source
37
+ comment `revision`; reads repair interrupted cache updates. Never accept author ids from an
38
+ untrusted request or parse ordinary prose as identity.
39
+
40
+ Teams are organization resources, reusable across projects. `teams.addMember/removeMember` and
41
+ `teams.attach/detach` use the existing relationship store, with canonical team-member and
42
+ project-team edges. `teams.members(team)` and `teams.projects(team)` read membership and
43
+ attachment; `teams.assignees(project)` reads effective assignees. Read project teams through
44
+ `relationships.list({ from: project, type: PLANNING_PROJECT_TEAM, toKind: PlanningResourceKind.Team })`;
45
+ project assignees are the deduplicated union of attached teams. Team `externalId` may name an
46
+ external permission group; links do not mutate authentication group membership.
47
+
48
+ Hierarchy uses existing `parent` and direct `parents`, with optional derived `parentType`.
49
+ Declare compatible `parents: { types?, required? }` and `children: { types }` on card schemas.
50
+ Required primary parents cannot be cleared; safe reparenting is supported. Cycles, cross-organization
51
+ links, incompatible descendants and deleting an ordinary card with children are refused. The
52
+ nearest primary project selects schemas; all project ancestors participate in access checks.
53
+ Project `mode` is `opened` (default) or `closed`, separate from flow status; host policy may use it
54
+ for access decisions.
55
+
56
+ Declare `resources: true` on `makePlanningProtocols` to add assignee/team/comment/mention
57
+ `get`, `list`, `write` branches and use `planningResourceAliases`. Enable `definitions: true`
58
+ for assignee type schema writes. Use stock facade methods, handlers, stores and feeds on both
59
+ sides; resource commands and versions are validated by the shared wire contract.
60
+
61
+ Protocol responses use `PlanningReply<T>`: storage organization metadata (`entityId`) is absent
62
+ from records, schema scopes, receipts and commit frames. `planningReplyHelper.project` handles
63
+ only known record/envelope positions; custom fields, transition changes and schema definitions
64
+ remain opaque. Native facades retain their trusted storage scope. Remote facades hydrate the
65
+ shared record shape with empty advisory metadata; never derive organization identity from it.
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: errors
3
+ description: Every refusal class with its planning marker and HTTP status, the httpStatus rules for a new refusal, and the eight-language i18n the package registers.
4
+ metadata:
5
+ purposes: feedback
6
+ mentions: PlanningError, WorkcardNotFound, IllegalTransition, WorkcardConflict, PlanningRefused, FieldsInvalid, SchemaConflict, SchemaSealed, SchemaInvalid, PlanningForbidden, CommitTimeout, CommitFailed, lib:planning
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/planning — Errors and i18n
11
+
12
+ ## Errors
13
+
14
+ Every refusal is a registered `ResilientError` whose message is `planning:<marker>:<detail>` —
15
+ the marker survives a hop where the class is unknown, so match on it there:
16
+
17
+ | Class | Marker | HTTP |
18
+ |---|---|---|
19
+ | `PlanningError` | `planning:` (`fold:out-of-order`, `immutable:<field>`, `malformed:<what>`) | 500 |
20
+ | `WorkcardNotFound` | `workcard-not-found:` — also the answer for another entity's card | 404 |
21
+ | `UnknownWorkcardType` / `UnknownStatusFlow` | `unknown-type:` / `unknown-flow:` | 422 |
22
+ | `ParentNotFound` / `CardTypeNotAllowed` | `parent-not-found:` / `card-type-not-allowed:` | 422 |
23
+ | `PlanningRefused` | `refused:` — a plugin middleware said no | 422 |
24
+ | `IllegalTransition` | `illegal-transition:<flow>:<transition>:<from>` | 409 |
25
+ | `FieldsInvalid` / `LabelNotAllowed` | `fields-invalid:` / `label-not-allowed:` | 422 |
26
+ | `SpecificationSlotUnknown` / `SpecificationRevisionConflict` | `specification-slot-unknown:` / `specification-revision-conflict:` | 422 / 409 |
27
+ | `RelationshipRefused` / `CodeTaken` | `relationship-refused:` / `code-taken:` | 422 / 409 |
28
+ | `WorkcardConflict` | `workcard-conflict:` — stale `expectSeq` | 409 |
29
+ | `SchemaConflict` / `SchemaInUse` | `schema-conflict:` (lost version CAS) / `schema-in-use:` (retiring a flow a live type runs) | 409 |
30
+ | `SchemaSealed` / `SchemaInvalid` | `schema-sealed:` / `schema-invalid:` | 422 |
31
+ | `PlanningForbidden` | `forbidden:<grant>:<target>` — the request's access lacks a grant; `forbidden:writes:<id>` — the card or project is outside its `writes` | 403 |
32
+ | `CommitTimeout` / `CommitFailed` | `commit-timeout:` (still pending, nothing undone) / `commit-failed:` | 500 |
33
+ | `PlanningScopeMismatch` / `PlanningUnsupported` | `scope-mismatch:` / `unsupported:` | 404 / 500 |
34
+
35
+ Type names are `PlanningError<Suffix>` (`PlanningErrorWorkcardNotFound`).
36
+
37
+ **The HTTP column is each class's `static httpStatus`** (the `error` skill's principle; 500 = none
38
+ declared): an addressed card that is absent or another entity's is 404, a write the request's
39
+ access does not grant is 403, a card whose state or head (or a schema record whose version)
40
+ refuses the write is 409, a body naming what the registry, the parent or a schema refuses is 422.
41
+ Faults declare nothing, and so does `PlanningError` itself — its three causes share one class, and
42
+ a base's static would reach every subclass. A new refusal declares on its own leaf class, and
43
+ `tests/error-status.spec.ts` refuses an exported refusal whose status was not decided. A plugin's
44
+ plain `throw` inside `before` becomes `PlanningRefused` and answers 422, so a plugin FAULT must
45
+ throw a `ResilientError` that declares nothing.
46
+
47
+ ## i18n
48
+
49
+ Importing the package registers, in eight languages (en, pl, ru, be, uk, es, de, fr), `lib:planning.*` labels (`kind.*`,
50
+ `intrinsic.*`, `action.*`, `commit.*`, `format.*`) and one text per refusal type under the shared
51
+ `errors` resource (`errors.<TypeName>`), where a panel's error lookup finds it.
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: fold
3
+ description: The pure fold (applyTransition, applyRelationship), what computeChanges records and assertMutable refuses, and the one query translation with its wire form.
4
+ metadata:
5
+ purposes: query, state
6
+ mentions: applyHelper, applyTransition, applyRelationship, changesHelper, computeChanges, assertMutable, isEmptyChange, queryHelper, criteriaOf, listOptionsOf, summaryOf, wireHelper, encodeWorkcardQuery, decodeWorkcardQuery, WorkcardQuery
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/planning — The fold and querying
11
+
12
+ ## The fold
13
+
14
+ `applyHelper.applyTransition(card | undefined, transition)` is pure and total:
15
+
16
+ - an already-applied seq (`seq <= card.seq`) returns the same card — re-folding is safe;
17
+ - anything but `card.seq + 1` (or seq 1 for a create with no card) throws
18
+ `PlanningError('fold:out-of-order')`;
19
+ - `create` builds the whole record from `changes` + `id`/`kind`/`type`/`entityId`/`createdAt`/`seq`;
20
+ - `update`/`transit` write `changes` — **`fields` and `flows` merge shallowly, every other key
21
+ replaces** — and clear `unset` (`key`, `fields.key`, `flows.id`); identity and required keys are
22
+ never written or cleared;
23
+ - `link`/`unlink` move only `seq`/`head`/`updatedAt`; `delete` answers `null`;
24
+ - every result carries `seq = transition.seq`, `head = max(head, seq)`, `updatedAt = transition.at`;
25
+ - the log is replayed AS WRITTEN: a stored row admission would refuse (an update naming
26
+ `createdBy`) still folds. Guards live at admission (`changesHelper.assertMutable`, the executor), never in the
27
+ fold, so a stored log never stops projecting.
28
+
29
+ `applyHelper.applyRelationship(links, transition)` folds the edges: a create's `links`, `link`, `unlink`, and a
30
+ `delete` dropping every edge touching the card. An existing edge is kept, so re-folding adds nothing.
31
+
32
+ `changesHelper.computeChanges(card, exec, type, registry, at, { slot? })` is what the executor records:
33
+
34
+ - create — the whole initial record: normalized `parents`, every flow at its initial status (the
35
+ draft's `status` for the primary), mirrored `status`/`intrinsic`, `closedAt` when it starts
36
+ closed; a specification gets its format, `bodyChars` and revision 1 when the slot is revisioned.
37
+ The code must already be on the draft (the executor mints first);
38
+ - update — only differing values; `null` or `exec.unset` clears a field that has one; a moved
39
+ `parent` replaces the old one in `parents`; changed content of a revisioned document sets
40
+ `revision + 1`. An update that changes nothing is `changesHelper.isEmptyChange` and must append nothing;
41
+ - transit — `flows[flow] = rule.to`, `status` on the primary flow, `intrinsic` when it moves,
42
+ `closedAt` set on entering closed and cleared on leaving; throws `IllegalTransition`;
43
+ - link/unlink/delete — nothing.
44
+
45
+ `changesHelper.assertMutable(exec, type)` refuses (`planning:immutable:<field>`) identity keys (`createdAt`
46
+ among them), `createdBy` in `changes` or `unset` on any action (a create included),
47
+ `revision` / `bodyChars`, `status`/`intrinsic`/`flows`/`closedAt` outside a create (they move only
48
+ through `transit`), `category`, a fixed `code`, and clearing a required field.
49
+
50
+ ## Querying
51
+
52
+ `queryHelper.criteriaOf(query, scope)` is the ONE translation from `WorkcardQuery` to `Criteria<Workcard>`:
53
+ bare values and arrays pass through (`kind`, `type`, `status`, `intrinsic`, `code`, `category`),
54
+ `parent` is direct children, `within` → `parents: { $contains }`, `labels` → `$overlaps`, `ids` →
55
+ `id: { $in }`, `flow` → `flows.<id>`, `fields` → `fields.<key>` equality, `q` → `$or` of title /
56
+ description `$ilike` (wildcards escaped) and code `$startsWith`, `updatedSince` → `updatedAt: {
57
+ $gte }`. `entityId` always comes from the scope; `undefined` is omitted. Paging and sort are
58
+ `queryHelper.listOptionsOf` (`size: 0` = no limit). `queryHelper.summaryOf(cards, parents)` counts DIRECT children by
59
+ intrinsic state and gives no key to a parent with none. A scope naming `projects` is narrowed by
60
+ the SERVER facade on top of this translation (`server-planning` → The access resolver);
61
+ `TransitionWhere.project` and `RelationshipWhere.project` take one id or a list.
62
+
63
+ **A query crosses HTTP in its wire form.** The OwlMeans transport cannot carry arrays or nested
64
+ objects in a query string (the client writes `key[]=`, the server reads that key literally), so
65
+ the list, summary, transitions, specifications and links protocols take `*QueryWire` shapes: lists
66
+ comma-joined (a JSON array when an entry holds a comma), objects as JSON, sort as `field,-field`.
67
+ A client calls `wireHelper.encodeWorkcardQuery(query)` before `call({ query })`; a handler calls
68
+ `wireHelper.decodeWorkcardQuery(req.query)` — tolerant of arrays and of the rich form — and then
69
+ `queryHelper.criteriaOf`. A value that does not decode throws `PlanningError('malformed:query:<key>')`.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: mounting
3
+ description: The protocol tree makePlanningProtocols declares (leaves, routes, aliases, guards, the commit socket) and how a generated target mounts it.
4
+ metadata:
5
+ purposes: server-api, config
6
+ mentions: makePlanningProtocols, planningAliasHelper, planningAliases, planningDefinitionAliases, PLANNING_PATH, socketBase, ExecuteRequest, CommitQuery, CommitFeedQuery, schema.define
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/planning — The protocol tree and mounting
11
+
12
+ ## The protocol tree and mounting
13
+
14
+ `makePlanningProtocols({ base: { alias, path?, parent?, service? }, guards, gate?, socketBase?, definitions? })`:
15
+
16
+ | Leaf | Route | Request → reply |
17
+ |---|---|---|
18
+ | `schema.list` | GET `/schemas` | → `PlanningSchemaBundle` |
19
+ | `card.list` | GET `/cards` | query `WorkcardQueryWire` → `ListResult<Workcard>` |
20
+ | `card.summary` | GET `/cards/summary` | query `SummaryQueryWire` → `SummaryView` |
21
+ | `card.get` | GET `/cards/:id` | → `Workcard` |
22
+ | `card.transitions` | GET `/cards/:id/transitions` | query `TransitionQueryWire` |
23
+ | `card.specifications` | GET `/cards/:id/specifications` | query `SpecificationQueryWire` |
24
+ | `spec.get` / `spec.revisions` | GET `/specifications/:id` / `…/revisions` | query `RevisionsQuery` |
25
+ | `link.list` | GET `/links` | query `RelationshipQueryWire` |
26
+ | `transition.get` | GET `/transitions/:transition` | → `Transition` |
27
+ | `execute` | POST `/execute` | body `ExecuteRequest` → `TransitionReceiptView` |
28
+ | `commit.get` | GET `/commits/:transition` | query `CommitQuery { wait? }` → `CommitStatus` |
29
+ | `commit.events` | SOCKET `/commits` | query `CommitFeedQuery` → `CommitEvent` frames |
30
+ | `schema.define` (with `definitions: true`) | POST `/schemas` | body `SchemaDefineRequest { project?, mode?, types?, flows?, retire? }` → `SchemaDefineReply { records, bundle }` |
31
+
32
+ - Aliases derive from the base alias (`planningAliasHelper.planningAliases(base)` → `<base>:card:list`, …); two mounts
33
+ are two base aliases. `definitions: true` adds `schema.define` (its alias from
34
+ `planningAliasHelper.planningDefinitionAliases(base)`, kept apart so a tree without it names no undeclared alias)
35
+ and gives `schema.list` a `SchemaListQuery { project? }` answered with that layer's scoped bundle.
36
+ Without it the tree declares exactly the other leaves.
37
+ - The guards and the gate sit on the base alone; every HTTP leaf inherits them. `path` defaults to
38
+ `/planning`.
39
+ - Static `/cards/summary` is declared before parametric `/cards/:id`.
40
+ - The commit socket hangs under `socketBase` when given and then inherits THAT base's guards —
41
+ pass an update/socket base that authenticates; otherwise it hangs under the planning base.
42
+ - `actor` in an execute body is ignored on the wire: the server fills it from the request.
43
+ - Responses carry no runtime schema (typed only), so a serializer never strips `fields`.
44
+
45
+ ## Mounting in a target
46
+
47
+ A generated target serves this tree as it is — no hand-written card, transition, schema or document
48
+ routes; domain rules are `before` plugins. This section is the hub; each package skill has its half.
49
+
50
+ - **common** declares the one tree the api and the web share: `makePlanningProtocols({ base: {
51
+ alias: 'api:planning' }, guards: DEFAULT_GUARD, definitions: true, resources: true })`, in its own module beside
52
+ the `PLANNING` plugin (`sources/common/src/planning.ts`).
53
+ - **api** — `server-planning` → Mounting in a target (stock handlers, no commit socket, the
54
+ resolver answering `writes`). **web** — `client-planning` → Mounting in a target. **store** —
55
+ `planning-postgres`.
56
+ - **Kit literals**: card types and flows are LITERAL objects in `PLANNING.schemas.flows` / `.types`,
57
+ above the `// owlmeans: add planning flows|types above this line` sentinels — a spread or an
58
+ imported constant hides them from the library's registration checks. Card types and flows carry
59
+ `overridable: true` with open `fields` (`additionalProperties: true`); the container project type
60
+ (always sealed) carries `scopedCardTypes: true`, so the app's people override and extend them as
61
+ data through `schema.define`.
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: types
3
+ description: Declaring card, project and specification types and status flows as closed data, the data-defined layers (code, entity, project) and retirement, and the rules every record schema keeps.
4
+ metadata:
5
+ purposes: data
6
+ mentions: WorkcardTypeSchema, ProjectTypeSchema, StatusFlowSchema, SpecificationSlot, CodePolicy, makeSchemaRegistry, scopedSchemaHelper, resolveScopedBundle, ScopedSchemaRecord, ScopedSchemaBundle, SchemaStore, putType, putFlow, putAssigneeType, scopedCardTypes, overridable
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/planning — Types, flows and schemas
11
+
12
+ ## Declaring a type
13
+
14
+ A `WorkcardTypeSchema` / `ProjectTypeSchema` is plain data registered through a plugin's `schemas`
15
+ or `makeSchemaRegistry({ types, flows })`. **Every declaration schema is closed** — these are ALL
16
+ the keys:
17
+
18
+ | Declaration | Required | Optional |
19
+ |---|---|---|
20
+ | card / specification type | `type` (its key), `kind`, `version`, `fields`, `flows` (at least one), `specifications` | `intrinsic`, `relationships`, `labels`, `code`, `label`, `tone`, `icon`, `overridable` |
21
+ | project type | the same, plus `cardTypes` | `projectTypes`, `scopedCardTypes` |
22
+ | flow | `id` (its key), `version`, `statuses` (at least one), `transitions` | `label`, `tone`, `icon`, `overridable` |
23
+ | status | `key`, `intrinsic` | `initial`, `terminal`, `label`, `tone` |
24
+ | transition rule | `name`, `from` (status keys or `'*'`), `to` | `label`, `explicit` |
25
+
26
+ The display name is `label` everywhere; a type or a flow has no `name`, `key` or `description`
27
+ (`key` belongs to a status, `name` to a rule). As data, `definitions` refuses an extra key with
28
+ `SchemaInvalid`; in code the registry does not validate a declaration, so only TypeScript's
29
+ excess-property check catches it — keep the literal typed.
30
+
31
+ - `fields` — the AJV schema of `fields`; the registry compiles and caches it (`validator(type)`).
32
+ - `specifications` — the slots this type's cards carry as children. A slot may name its
33
+ specification `type`; otherwise `specificationHelper.specificationTypeOf` takes the only registered specification
34
+ type, then the one sharing the parent type's prefix (`viable:project` → `viable:spec`).
35
+ - `code` — `CodePolicy { prefix?, style: random|sequential|slug, length?, uppercase?, uniqueWithin:
36
+ parent|entity, mutable? }`. A code is fixed once minted unless `mutable`.
37
+ - `relationships` — the edge types a card may start (`single` = at most one per card).
38
+ - `labels` — the allowed labels (any when omitted).
39
+ - A project type lists `cardTypes` (and `projectTypes` for nesting) its children may have;
40
+ `scopedCardTypes: true` also admits card types defined as data (below).
41
+ - `overridable: true` on a code type or flow opens it to a data-defined override.
42
+ - `tone` / `icon` (type, flow) and `tone` (status) — PRESENTATION hints the package stores as free
43
+ text of at most 64 characters, neither validating nor rendering them. Their standard values and
44
+ their standard editor and renderer are `@owlmeans/web-panel/picker`: `tone` a palette key
45
+ (`PICKER_COLOR_KEYS` — red … graphite, 24) or a lowercase `#rrggbb` when the editor allows custom
46
+ colours, `icon` a kebab-case lucide name of its catalogue (`PICKER_ICON_NAMES`). A person edits a
47
+ type's or a flow's mark with `IconColorPicker` and shows it with `IconTile` / `ColorDot`; a
48
+ status's `tone` usually names a meaning the application's own palette maps (`ColorPicker` with
49
+ `colors`). An unknown value renders the picker's fallback (`slate`, `circle`); server code and
50
+ seeds read the data React-free from `@owlmeans/web-panel/picker/vocabulary`.
51
+
52
+ ## Data-defined types and flows
53
+
54
+ Card types, assignee types and status flows may also be DATA — `ScopedSchemaRecord`s a store keeps
55
+ behind the optional `PlanningStore.schemas` port. Card types and flows layer **code → entity → project**;
56
+ assignee types layer **code → entity** and refuse a project-local declaration:
57
+
58
+ - `code` is the plugin registry (the default), `entity` an organization-wide record
59
+ (`project` absent), `project` a record of one project card. A project record overrides an
60
+ organization one of the same key.
61
+ - **Card types, assignee types and flows are data.** A code key is sealed unless its declaration says
62
+ `overridable: true`; project and specification types are always code's (`scopedSchemaHelper.assertOverridable` →
63
+ `SchemaSealed`).
64
+ - A record's `version` is its compare-and-set token and is written into the declaration's own
65
+ `version`: a write lands only at its layer's next version, 1 for a new key (`SchemaConflict`).
66
+ - The closed-form checks are shared by every store: `scopedSchemaHelper.assertFlowSchema` (the declaration's schema,
67
+ unique status keys, every `to` declared, `from` `'*'` or declared statuses) and
68
+ `scopedSchemaHelper.assertTypeSchema` (the `card` kind, a compiling `fields` schema, unique flows that resolve) —
69
+ `SchemaInvalid`.
70
+ - **Retired** means offered for nothing new, still resolved for what uses it: a retired record
71
+ gives way to a live declaration below it, and a key retired in every layer resolves retired —
72
+ present in the bundle's `types`/`flows`, listed in `retired`. A flow cannot be retired while a
73
+ live type of an affected layer still runs it (`scopedSchemaHelper.flowInUse` → `SchemaInUse`).
74
+ - `scopedSchemaHelper.resolveScopedBundle(code, records, scope)` is the ONE resolution (pure);
75
+ `scopedRegistryOf` is its read-only registry (`originOf`, `isRetired`; `register*`/`load` refuse). A
76
+ `ScopedSchemaBundle` extends the plain bundle with `scope`, `revision`, `origins`, `retired`, so a
77
+ reader of the plain bundle is unaffected.
78
+ - `SchemaStore`: `list(where)`, `put(record)` (the CAS), `purge({ entityId, project })`,
79
+ `revision(entityId)` (monotonic per organization), optional `watch(listener)`.
80
+ - `PlanningFacade.definitions` (present only where the store has the port): `bundle(project?)`,
81
+ `registry(project?)`, `records`, `putType`/`putFlow`/`putAssigneeType` (CAS on the declaration's version),
82
+ `define` (each at its layer's next version, flows first), `seed` (only the keys the layer lacks),
83
+ `retire(kind, key, { project? })`. The declaration still carries its own `version` (any number
84
+ ≥ 1 for `define`/`seed`, which assign the layer's next one; the exact next one for `put*`), and
85
+ a project layer is `{ project: <project card id> }` in the second argument (a `define` call:
86
+ `server-planning` → Creating and reading a card).
87
+
88
+ ## Schema rules
89
+
90
+ A record schema may become a Mongo `$jsonSchema`, so every exported schema keeps two rules: **no
91
+ `integer`** (`number` only) and **every optional property `nullable: true`** (an enum then admits
92
+ `null` too). No `oneOf`/`anyOf` inside a record schema. Build type arrays per schema, never share
93
+ one object between schemas — ajv may extend a `type` array while compiling. The walk that pins this
94
+ is part of the package's own tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/planning",
3
- "version": "0.1.18-rc.28",
3
+ "version": "0.1.18-rc.29",
4
4
  "license": "MIT",
5
5
  "description": "Runtime-free project-planning contracts — the workcard super type, projects, specifications, typed relationships, status flows with three intrinsic states, the transition event and its pure fold, the schema registry, the models and the protocol tree.",
6
6
  "type": "module",
@@ -23,14 +23,14 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
- "@owlmeans/auth": "^0.1.18-rc.41",
27
- "@owlmeans/basic-ids": "^0.1.18-rc.40",
28
- "@owlmeans/context": "^0.1.18-rc.34",
29
- "@owlmeans/entrypoint": "^0.1.18-rc.41",
30
- "@owlmeans/error": "^0.1.18-rc.38",
31
- "@owlmeans/i18n": "^0.1.18-rc.39",
32
- "@owlmeans/resource": "^0.1.18-rc.40",
33
- "@owlmeans/route": "^0.1.18-rc.35"
26
+ "@owlmeans/auth": "^0.1.18-rc.42",
27
+ "@owlmeans/basic-ids": "^0.1.18-rc.41",
28
+ "@owlmeans/context": "^0.1.18-rc.35",
29
+ "@owlmeans/entrypoint": "^0.1.18-rc.42",
30
+ "@owlmeans/error": "^0.1.18-rc.39",
31
+ "@owlmeans/i18n": "^0.1.18-rc.40",
32
+ "@owlmeans/resource": "^0.1.18-rc.41",
33
+ "@owlmeans/route": "^0.1.18-rc.36"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "ajv": "*",