@sleepy_panda_srl/portulan 0.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.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
package/spec/slots.md
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
# The slots, and where each came from
|
|
2
|
+
|
|
3
|
+
> Companion to [`workspace.schema.json`](workspace.schema.json). One section per slot: what it holds,
|
|
4
|
+
> **why it is a slot at all**, what it was derived from, and what [`doctor`](../cli/doctor.mjs) checks
|
|
5
|
+
> about it. Every "what `doctor` checks" line below was a specification of intent when this file was
|
|
6
|
+
> written and is a description of behaviour now — the second milestone-2 session built the validator,
|
|
7
|
+
> and where it does **less** than a line here promised, the line says so rather than being quietly
|
|
8
|
+
> softened.
|
|
9
|
+
>
|
|
10
|
+
> The derivation column is not decoration. A schema is the easiest place in a product to smuggle in
|
|
11
|
+
> invention — a slot added for symmetry costs every future adopter a decision they did not need to make.
|
|
12
|
+
> Every slot below traces to at least one of three sources, and a slot that traced to none was cut:
|
|
13
|
+
>
|
|
14
|
+
> - **criterion** — the milestone-2 exit criterion in [`../docs/plan.md`](../docs/plan.md), which names
|
|
15
|
+
> the slots this milestone owes.
|
|
16
|
+
> - **constitution** — [`../docs/vision.md`](../docs/vision.md): thesis 1's definition of a workspace, or
|
|
17
|
+
> another clause named inline. The parenthetical is deliberately **not** reproduced here. A quotation of
|
|
18
|
+
> the constitution inside a document about accurate derivation is a drift surface, and this one had
|
|
19
|
+
> already drifted: an earlier draft appended the milestone-2 criterion's *"plus the constitution and
|
|
20
|
+
> product-layer slots"* to thesis 1's own list, which does not contain that phrase. Follow the link.
|
|
21
|
+
> - **content** — the one real workspace that existed when this was written,
|
|
22
|
+
> [`../.portulan/`](../.portulan/). The plan sequences re-expression before schema on purpose: derive
|
|
23
|
+
> the spec from real material rather than guessing it.
|
|
24
|
+
>
|
|
25
|
+
> Fragments below use `{braces}` for placeholders, as [`../core/templates/`](../core/templates/) does.
|
|
26
|
+
> Nothing here embeds customer zero's prose — it is cited by link, because a spec that quoted the first
|
|
27
|
+
> workspace would outlive it badly.
|
|
28
|
+
|
|
29
|
+
## The minimality rule
|
|
30
|
+
|
|
31
|
+
A slot is **required** only if a workspace lacking it breaks a promise the engine makes. Everything else
|
|
32
|
+
is optional, however good an idea it is. The pressure runs the other way — a schema author's instinct is
|
|
33
|
+
that a workspace *should* have memory, handoffs, and proposals — but a team adopting Portulan on a
|
|
34
|
+
Tuesday afternoon has none of those yet, and a definition that rejects them has made the first
|
|
35
|
+
experience a failure. *(Binding non-goal: no ceremony that can't scale down.)*
|
|
36
|
+
|
|
37
|
+
## Quick reference
|
|
38
|
+
|
|
39
|
+
| Slot | Carrier | Required | Derived from |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| `portulan.spec` | manifest | **yes** | constitution — the plan's Versioning protocol; migrations need a version to compare |
|
|
42
|
+
| `name` | manifest | **yes** | constitution — workspaces ship as plugins through a feed, so they need a stable identifier |
|
|
43
|
+
| `kind` | manifest | **yes** | content — [`three-workspaces-not-one.md`](../.portulan/memory/three-workspaces-not-one.md); constitution — *Reference workspaces* |
|
|
44
|
+
| `summary` | manifest | no | constitution — attention budgets per layer |
|
|
45
|
+
| `slots.identity` | path | **for a governing kind** | constitution — thesis 1 *(identity, stack, glossary)*; content — [`identity.md`](../.portulan/identity.md) |
|
|
46
|
+
| `slots.principles` | path | **for a governing kind** | criterion — *constitution … slots (team principles)*; content — [`principles.md`](../.portulan/principles.md) |
|
|
47
|
+
| `slots.constitution` | path | no | content — customer zero is graded against a document outside its workspace; constitution — spec-driven school, the constitution file |
|
|
48
|
+
| `slots.gates` | path | **for a governing kind** | criterion — *gate map*; constitution — [`autonomy.md`](../core/operating/autonomy.md) puts the map in the workspace |
|
|
49
|
+
| `slots.dod` | path | no | constitution — thesis 1 *(DoD)*; [`verification.md`](../core/operating/verification.md) — core supplies the floor |
|
|
50
|
+
| `slots.memory` | path | no | constitution — thesis 1 *(memory)*; [`memory.md`](../core/operating/memory.md) |
|
|
51
|
+
| `slots.repos` | path | no | constitution — thesis 1 *(repo cards)*; [`repo-card.md`](../core/templates/repo-card.md) |
|
|
52
|
+
| `slots.tasks` | path | no | content — [`tasks/`](../.portulan/tasks/); constitution — BMAD, the story file as atomic context unit |
|
|
53
|
+
| `slots.handoffs` | path | no | content — [`handoffs/`](../.portulan/handoffs/); [`loop.md`](../core/operating/loop.md) — every session ends with one |
|
|
54
|
+
| `slots.proposals` | path | no | content — [`proposals/`](../.portulan/proposals/); [`evolution.md`](../core/operating/evolution.md) |
|
|
55
|
+
| `verify` | structured | **for a governing kind** | criterion — *verify recipes*; [`verification.md`](../core/operating/verification.md) — the workspace sets the default |
|
|
56
|
+
| `governed_by` | manifest | **for `kind: pointer`** | the maintainer's residence ruling, 2026-07-30 — [proposal 0017](../.portulan/proposals/0017-one-repository-one-governing-workspace.md); one repository, one governing workspace |
|
|
57
|
+
| `products[]` | structured | no | criterion — *product-layer slot … portfolio-aware*; content — [`identity.md`](../.portulan/identity.md) |
|
|
58
|
+
| `affordances` | path | no | criterion — *agent-affordances slot*; constitution — the agent-native / AX row of the influence map |
|
|
59
|
+
| `tree` | path | **for `kind: repository`** | criterion — *lints workspace claims against the tree*; content — [`../examples/`](../examples/), the first workspace whose repositories are not present |
|
|
60
|
+
| `packs` | structured | no | constitution — thesis 1's cascade, `core < pack < workspace` |
|
|
61
|
+
| `memory` | structured | no | criterion — milestone 5, *generated size-budgeted index whose budget is a rail*; [`memory.md`](../core/operating/memory.md) — the Index and Consolidate states of the lifecycle |
|
|
62
|
+
| `handoffs` | structured | no | criterion — milestone 5 as amended, *a generated index over the handoff series*; [`loop.md`](../core/operating/loop.md) — the librarian that mines the series |
|
|
63
|
+
| `provenance` | record field | **on every rule** | criterion — *provenance slot*; [proposal 0002](../.portulan/proposals/0002-sealed-provenance.md), adopted |
|
|
64
|
+
|
|
65
|
+
## `kind` — which of the four workspace kinds this is, and which of them govern
|
|
66
|
+
|
|
67
|
+
The strongest single piece of evidence in the tree asked for this slot by name. The memory entry
|
|
68
|
+
[`three-workspaces-not-one.md`](../.portulan/memory/three-workspaces-not-one.md) records its own
|
|
69
|
+
retirement condition as *"the Workspace Definition (milestone 2) names and distinguishes the three
|
|
70
|
+
formally"* — so the schema is not inventing a distinction, it is discharging one the workspace had
|
|
71
|
+
already written down and was waiting on.
|
|
72
|
+
|
|
73
|
+
`repository` · `demo` · `portfolio` — the three that **govern** — and, added at 2.7, `pointer`, which
|
|
74
|
+
does not. **What `doctor` checks:** that the value is one of the four, and then which form the manifest
|
|
75
|
+
must take (below). What it cannot check is the thing that matters about the first three — whether a
|
|
76
|
+
`demo` workspace actually carries no real internal policy. That stays human review, and it is the reason
|
|
77
|
+
the slot is required rather than inferred from a path: an author who has to type `demo` has been asked
|
|
78
|
+
the question.
|
|
79
|
+
|
|
80
|
+
### `pointer` — a repository whose workspace resides elsewhere
|
|
81
|
+
|
|
82
|
+
Added at **2.7**, from the maintainer's residence ruling of 2026-07-30
|
|
83
|
+
([proposal 0017](../.portulan/proposals/0017-one-repository-one-governing-workspace.md)):
|
|
84
|
+
|
|
85
|
+
> A repository is governed by exactly one workspace. It carries its own full workspace, or a pointer to
|
|
86
|
+
> the workspace that names it — never both.
|
|
87
|
+
|
|
88
|
+
A team's workspace can reside in two places, and the choice is the customer's: **in the repository**, the
|
|
89
|
+
shape customer zero has, self-contained and feed-independent; or **feed-side**, in a portfolio workspace
|
|
90
|
+
that names the repository, which reaches many repositories at once and keeps team context out of product
|
|
91
|
+
trees whose audiences may be wider. They are one artifact in two residences. What differs is reach and
|
|
92
|
+
delivery; what does not differ is what the workspace contains, or what a customer can do with it.
|
|
93
|
+
|
|
94
|
+
Which is exactly why both may not be present at once. Two residences of the same thing are not two
|
|
95
|
+
things — they are one thing written down twice, with nothing holding the copies in agreement. `pointer`
|
|
96
|
+
is what makes the second residence expressible without a second copy: a manifest that names its governor
|
|
97
|
+
and carries no policy layer of its own.
|
|
98
|
+
|
|
99
|
+
**Why a `kind` value rather than a separate file.** The boot skill looks for exactly one path,
|
|
100
|
+
`${CLAUDE_PROJECT_DIR}/.portulan/workspace.json`, and does not search outward. A pointer that lived
|
|
101
|
+
anywhere else would be invisible to the one thing that most needs to read it, and a repository governed
|
|
102
|
+
from a feed would be indistinguishable from a repository that never adopted Portulan.
|
|
103
|
+
|
|
104
|
+
**What `doctor` checks:** that a pointer carries `governed_by`, and that it carries no *governing* keys —
|
|
105
|
+
no slots, no recipes, no products, no packs, no store. The permit-list is exactly `portulan`, `name`,
|
|
106
|
+
`kind`, `governed_by` and an optional `summary`; `summary` is on it because one line an agent reads before
|
|
107
|
+
loading anything else costs nothing and is the difference between "governed elsewhere" and a bare name.
|
|
108
|
+
The schema requires `governed_by` through its `oneOf`, and `doctor` refuses anything outside the
|
|
109
|
+
permit-list in the ruling's own words. It
|
|
110
|
+
also skips every governing-workspace check for a pointer **and says so**: a pointer declares no recipes,
|
|
111
|
+
so `verify.default` alone would fail every compliant one, and a check that vanishes without a word is
|
|
112
|
+
worse than one that admits what it could not reach.
|
|
113
|
+
|
|
114
|
+
**What `doctor` resolves and does not grade, since milestone 7:** `governed_by` is dereferenced against
|
|
115
|
+
the host's installed-plugin record by [`../cli/discover.mjs`](../cli/discover.mjs) — on disk, never over
|
|
116
|
+
the network — and the answer is **reported**. Where the governing workspace is installed, the report
|
|
117
|
+
names the directory and what to run against it; where it is not, the honest *not installed here*
|
|
118
|
+
sentence; where two installs answer to one name, both are named and neither is chosen. **No outcome
|
|
119
|
+
moves the verdict**: a pointer whose governor is uninstalled is a correct pointer, and a tool whose exit
|
|
120
|
+
code turned on somebody's install state would be judging a host rather than a workspace.
|
|
121
|
+
|
|
122
|
+
**What nothing checks:** that the workspace a pointer names *governs*, or is green. Being resolvable
|
|
123
|
+
says where it is, and running `doctor` against that directory is the separate act that grades it — see
|
|
124
|
+
[`README.md`](README.md)'s coverage table.
|
|
125
|
+
|
|
126
|
+
## `slots.identity` and `slots.principles` — and why they are two
|
|
127
|
+
|
|
128
|
+
Thesis 1 lists identity, stack, and glossary as one cluster; the criterion separately names a
|
|
129
|
+
constitution slot glossed as *team principles*. Customer zero had all of it in one document, and the
|
|
130
|
+
split was forced by the no-fragments rule rather than chosen: two slots cannot address two halves of one
|
|
131
|
+
file when a slot must be a whole file.
|
|
132
|
+
|
|
133
|
+
The split turned out to be the right cut anyway. **Identity** answers *who we are and what we work with*
|
|
134
|
+
— team shape, stack, glossary. **Principles** answers *how we decide*, and it is the half other work is
|
|
135
|
+
graded against. They change on different clocks: a stack row turns over every year or two, a principle
|
|
136
|
+
almost never.
|
|
137
|
+
|
|
138
|
+
**What `doctor` checks:** both resolve to files that exist — files specifically, not directories, since
|
|
139
|
+
both are declared `filePath`.
|
|
140
|
+
|
|
141
|
+
## `slots.constitution` — optional, and the one slot expected to escape
|
|
142
|
+
|
|
143
|
+
The criterion glosses the constitution slot as team principles, which is right for most adopters: their
|
|
144
|
+
principles *are* the standard they are graded against. Customer zero is the case that shows those can be
|
|
145
|
+
two different things — its principles are workspace policy, while
|
|
146
|
+
[`../docs/vision.md`](../docs/vision.md) is the product's constitution, human-owned, sitting outside the
|
|
147
|
+
workspace directory entirely.
|
|
148
|
+
|
|
149
|
+
So the schema takes both: `principles` is required and lives in the workspace; `constitution` is optional
|
|
150
|
+
and may point anywhere. Had containment been required, `doctor`'s first run against its own author's
|
|
151
|
+
workspace would have gone red for a schema-design reason rather than a content one — a good early
|
|
152
|
+
warning, arriving after the design was frozen.
|
|
153
|
+
|
|
154
|
+
**What `doctor` checks:** the path resolves. It also **reports — never fails —** when *any* path slot's
|
|
155
|
+
target escapes the workspace directory. The distinction matters for whoever implements it: the schema
|
|
156
|
+
permits escaping everywhere, because a `pattern` sees a string and not the key it belongs to, so "only
|
|
157
|
+
`constitution` may escape" is a convention this document states and `doctor` surfaces, not a constraint
|
|
158
|
+
the schema carries. Reporting is the right severity — a workspace embedded in a larger repository may
|
|
159
|
+
legitimately reach for a shared document, and failing would make the tool wrong about a case it cannot
|
|
160
|
+
see.
|
|
161
|
+
|
|
162
|
+
One consequence worth naming before `doctor` is written: this slot takes `$defs/path`, which admits a
|
|
163
|
+
directory as well as a file, so `"constitution": "docs/"` validates. That is deliberate — a team's
|
|
164
|
+
constitution may genuinely be a folder rather than one document — and it means `doctor` resolves the
|
|
165
|
+
target without assuming it is a file. Same severity as an escape: report what it found, do not fail.
|
|
166
|
+
|
|
167
|
+
## `slots.gates` — the policy half of autonomy
|
|
168
|
+
|
|
169
|
+
[`autonomy.md`](../core/operating/autonomy.md) defines the tiers as universal mechanism and says
|
|
170
|
+
explicitly that which concrete action lands in which tier is workspace policy. That makes the gate map
|
|
171
|
+
the one slot where core has already promised the workspace will answer, so a workspace without it leaves
|
|
172
|
+
an engine promise unfulfilled — required, by the minimality rule's own test.
|
|
173
|
+
|
|
174
|
+
**One convention this slot carries, and it is load-bearing.** `doctor` finds the required-check claim by
|
|
175
|
+
looking for a Markdown table row whose first cell matches **`required status check`** (case-insensitive)
|
|
176
|
+
and reading the backticked values from the rest of the row. That is the only structure the tool expects
|
|
177
|
+
of a gate map — everything else in the file is prose it never parses. It is written here because nothing
|
|
178
|
+
else states it: there is no gate-map template in [`../core/templates/`](../core/templates/), so an adopter
|
|
179
|
+
whose floor table uses a different row label gets the check **skipped** — no comparison happens at all.
|
|
180
|
+
It is no longer *silent*: `doctor` reports that the gate map names no required status check, and names
|
|
181
|
+
both readings, so a reader cannot mistake *this workspace requires none* for *I did not recognise your
|
|
182
|
+
table*. (It was silent — nothing reported, nothing counted — until a review measured it.) Naming the
|
|
183
|
+
convention is the cheap fix; a template is the better one and is not built.
|
|
184
|
+
|
|
185
|
+
**What `doctor` checks:** the path resolves; and, where the workspace declares a `tree`, that **every**
|
|
186
|
+
status check the gate map says `main` requires is one a workflow in that tree actually reports. That is
|
|
187
|
+
the gate map's half of the claims-against-the-tree lint, and it is narrower than "does not claim
|
|
188
|
+
enforcement the repository does not have": it compares named contexts against what the workflows declare.
|
|
189
|
+
|
|
190
|
+
Two details cost a false green each before a third workspace exposed them, and both are worth knowing
|
|
191
|
+
before writing a gate map. **A job's reported context is its `name:` when it has one, and its id
|
|
192
|
+
otherwise** — so a gate map naming the id of a job that carries a display name names something no check
|
|
193
|
+
will ever report, and `doctor` says exactly that rather than "no such job". And **a row may name several
|
|
194
|
+
checks**; reading only the first silently exempts the rest. Neither could surface on customer zero, whose
|
|
195
|
+
workflow deliberately sets no `name:` so that id and context coincide, and which requires exactly one
|
|
196
|
+
check.
|
|
197
|
+
|
|
198
|
+
Whether branch protection *really* requires those contexts, and which app they are pinned to, are live
|
|
199
|
+
settings — and a network call is not something a verify recipe here is allowed to make. Worth knowing
|
|
200
|
+
when checking by hand: a branch protected by a **repository ruleset** returns `404 Branch not protected`
|
|
201
|
+
from the classic `…/branches/main/protection` endpoint, which is a check reporting *not clear* about
|
|
202
|
+
something that is fine.
|
|
203
|
+
|
|
204
|
+
## `gates` — the policy, as distinct from the argument for it
|
|
205
|
+
|
|
206
|
+
**Added in 2.1, optional.** A path to a JSON file the enforcement compiler reads. It is a *second* key
|
|
207
|
+
beside `slots.gates`, and the duplication is the point rather than an oversight: they answer different
|
|
208
|
+
questions and only one of them compiles.
|
|
209
|
+
|
|
210
|
+
- **`slots.gates`** → Markdown. *Why* an action sits in a tier. Read by humans; no tool dispatches on it.
|
|
211
|
+
- **`gates`** → JSON. *Which* action sits in which tier, in a vocabulary a backend can translate.
|
|
212
|
+
|
|
213
|
+
The alternative shapes were both considered and both refused. **Generating the prose from the JSON** adds
|
|
214
|
+
a build step to a product whose thesis is that the files *are* the product. **Extracting the JSON from the
|
|
215
|
+
prose** needs the ambitious parser this document already warns against elsewhere — it would produce false
|
|
216
|
+
reds, and a false red is what gets a whole check switched off.
|
|
217
|
+
|
|
218
|
+
So two files state one policy, which is this repository's signature defect, and it is contained rather than
|
|
219
|
+
denied: every rule carries an `id`, the prose cites those ids in code spans, and **membership is checked
|
|
220
|
+
both ways** — a rule with no mention, or a mention with no rule, fails. The forward direction is exact. The
|
|
221
|
+
reverse recognises a citation by *shape*, so it is a strong check rather than a total one, and the workspace
|
|
222
|
+
that implements it says so at the point of use. What no check can hold is whether a
|
|
223
|
+
*sentence* contradicts the rule it names. Hence the tie-break, stated in both files: **where they disagree,
|
|
224
|
+
the policy wins**, because it is the one that compiles.
|
|
225
|
+
|
|
226
|
+
### The action vocabulary is the workspace's, never a host's
|
|
227
|
+
|
|
228
|
+
A rule says `{"shell": "git push"}`, not `Bash(git push:*)`. That is what keeps "LLM-agnostic by
|
|
229
|
+
construction" true at this layer: a second backend translates the same policy instead of forcing every
|
|
230
|
+
adopter to rewrite theirs. It is cheapest to hold while exactly one backend exists, which is why it was
|
|
231
|
+
held then.
|
|
232
|
+
|
|
233
|
+
### Four tier classes, where core names three
|
|
234
|
+
|
|
235
|
+
`auto` · `propose` · `gated` · **`prohibited`**. The fourth is not a stronger `gated` — it is a different
|
|
236
|
+
answer. Gated means *approvable per action* and compiles to a prompt; prohibited means *no approval
|
|
237
|
+
exists* and compiles to a refusal. A three-class policy would file "no agent edits the constitution" under
|
|
238
|
+
Gated, and the compiler would then emit a prompt — turning *never* into *unless someone clicks yes*.
|
|
239
|
+
|
|
240
|
+
### What a compiler must do with it
|
|
241
|
+
|
|
242
|
+
Two obligations, both learned from defects rather than designed in the abstract:
|
|
243
|
+
|
|
244
|
+
1. **Account for every rule.** Each ends as *compiled* or *refused with a stated reason*, and the two
|
|
245
|
+
counts sum to the input. A rule that goes in and produces nothing leaves a policy that reads as
|
|
246
|
+
enforced and a machine that enforces nothing.
|
|
247
|
+
2. **Refuse what it cannot compile.** An unknown tier or action shape fails the *whole* compile rather
|
|
248
|
+
than dropping one rule — skipping and enforcing are indistinguishable from outside
|
|
249
|
+
([`../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md`](../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md)).
|
|
250
|
+
|
|
251
|
+
### `floor` — the platform floor this policy compiles to
|
|
252
|
+
|
|
253
|
+
**Added in 2.2, optional**, inside the gate policy rather than the manifest — it is policy, and policy
|
|
254
|
+
lives where the rules live. Four keys, and each one exists because it varies per repository and the
|
|
255
|
+
floor backend would otherwise have to guess it:
|
|
256
|
+
|
|
257
|
+
| Key | What it declares |
|
|
258
|
+
|---|---|
|
|
259
|
+
| `branch` | The ref the floor protects. No default: a compiler that invents the ref it gates has stopped compiling policy and started writing it. |
|
|
260
|
+
| `checks` | The status checks that must be green, each `{ context, integration_id? }`. An unpinned context is satisfiable by *any* app reporting that name — permitted, and reported by `doctor`. |
|
|
261
|
+
| `reviews` | Required approving reviews. Declared rather than defaulted: 0 and 1 are a real difference, and a repository with one maintainer cannot use 1, because GitHub does not permit approving one's own pull request. |
|
|
262
|
+
| `resolve_conversations` | Whether review threads must be resolved before merge. Omitting it would export a floor weaker than the one many repositories already run. |
|
|
263
|
+
|
|
264
|
+
**What is deliberately NOT declarable is `strict`.** A pull request may not merge from behind its base
|
|
265
|
+
([proposal 0011](../.portulan/proposals/0011-no-merge-from-behind-main.md), applied live on
|
|
266
|
+
2026-07-27), so the export forces strict required status checks unconditionally. A policy able to
|
|
267
|
+
declare `strict: false` would be a compiled artifact quietly undoing a ruling, in a diff nobody would
|
|
268
|
+
read as one.
|
|
269
|
+
|
|
270
|
+
A workspace with no `floor` compiles no floor: the backend refuses every rule *by name*, says the key
|
|
271
|
+
is missing, and writes nothing. That is a legitimate shape — a workspace whose repositories are not on
|
|
272
|
+
GitHub, or whose floor is configured by hand, or which has not got there yet. It is **not** the demo
|
|
273
|
+
workspace's, which declares no gate policy at all and so never reaches this backend; saying otherwise
|
|
274
|
+
would credit a fixture with exercising a path it does not.
|
|
275
|
+
|
|
276
|
+
**What `doctor` checks:** that the path resolves; that every `floor.checks` context is reported by a
|
|
277
|
+
workflow job in the declared tree (a **failure** — a required context that never reports blocks every
|
|
278
|
+
pull request, and `enforce_admins` leaves nobody able to force past it); and, as a report, how much of
|
|
279
|
+
the policy each backend compiles, which gates no backend compiles at all, and whether the `floor` and
|
|
280
|
+
the gate map's required-check row agree. It does **not** compile the artifact or check the prose
|
|
281
|
+
citations — that lives in the compiler's suite, anchored to the real tree, because the citation
|
|
282
|
+
convention is this repository's and not the spec's. A workspace may declare prose and no policy; that
|
|
283
|
+
is an ordinary state, and it is the demo workspace's state.
|
|
284
|
+
|
|
285
|
+
## `verify` — the only slot that is structured because it is *consumed*
|
|
286
|
+
|
|
287
|
+
Every other content slot points at Markdown for a human. This one carries data because the Stop-gate
|
|
288
|
+
needs an answer, not a paragraph: which recipe runs when nothing more specific applies. A prose sentence
|
|
289
|
+
saying "run the docs recipe" cannot be dispatched.
|
|
290
|
+
|
|
291
|
+
**As of milestone 2 that argument has a consumer rather than a promise behind it.** This repository's CI runs
|
|
292
|
+
the set the manifest **yields** — every recipe it declares, plus the ones its composed packs contribute
|
|
293
|
+
since milestone 7's composition amendment — computed by
|
|
294
|
+
[`../cli/recipe-set.mjs`](../cli/recipe-set.mjs) rather than enumerated in the workflow. So declaring a
|
|
295
|
+
recipe is what enforces it — no workflow edit, no branch-protection change, and no window in which a new
|
|
296
|
+
recipe exists but nothing runs it. It is a milestone early (the Stop-gate runner is milestone 4) and it is the cheapest possible
|
|
297
|
+
demonstration that a structured slot earns being structured. Recorded in customer zero's
|
|
298
|
+
[proposal 0004](../.portulan/proposals/0004-ci-runs-every-declared-recipe.md).
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
"verify": {
|
|
302
|
+
"default": "{recipe-id}",
|
|
303
|
+
"recipes": [
|
|
304
|
+
{ "id": "{recipe-id}", "run": "{command}", "requires": ["{tool}"], "doc": "{path}" }
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`requires` exists because of an exit code. The recipes distinguish *red* (exit 1, the check ran and
|
|
310
|
+
failed) from *could not run* (exit 2, a tool is missing), and that distinction is worthless if nothing
|
|
311
|
+
declares what was needed. Customer zero now has two recipes with different needs — one POSIX-only, one
|
|
312
|
+
requiring `node` — which is exactly the case that made the field earn its place.
|
|
313
|
+
|
|
314
|
+
**What `doctor` checks:** `default` names a recipe that exists in `recipes`; each `doc` **that is
|
|
315
|
+
present** resolves. `doc` is optional by the minimality rule — a recipe whose limits are not yet written
|
|
316
|
+
up is still a runnable recipe, and requiring the write-up would be exactly the ceremony that rule refuses.
|
|
317
|
+
It does **not** run the recipes, and it does not check that `requires` is honest — a recipe that quietly needs a
|
|
318
|
+
tool it did not declare passes. That gap narrowed in milestone 4 rather than closing: the Stop-gate runner
|
|
319
|
+
executes the **default** recipe, so that one recipe's `requires` is now tested by being run. Every other
|
|
320
|
+
declared recipe is still read and never executed, and `doctor` remains the wrong place to fix it — a
|
|
321
|
+
validator that ran arbitrary commands out of a manifest would be a validator you could not safely point
|
|
322
|
+
at someone else's workspace.
|
|
323
|
+
|
|
324
|
+
## `products[]` — repeated, even though customer zero has one
|
|
325
|
+
|
|
326
|
+
The criterion says *portfolio-aware: many products per workspace*, and the vision's Reference workspaces
|
|
327
|
+
bullet describes a Sleepy Panda SRL workspace covering **all** Sleepy Panda SRL products. So the product layer is
|
|
328
|
+
per-product from the start, not a workspace-level field that a later milestone has to unpick.
|
|
329
|
+
|
|
330
|
+
This is the schema's most deliberate act of resisting its own evidence. Derived faithfully from the only
|
|
331
|
+
available sample, `products` would be a singular object — customer zero has exactly one product, and a
|
|
332
|
+
one-sample derivation would have modelled it that way, correctly for today and wrongly for milestone 6.
|
|
333
|
+
|
|
334
|
+
```json
|
|
335
|
+
"products": [
|
|
336
|
+
{ "id": "{id}", "name": "{Display Name}",
|
|
337
|
+
"product": "{path}", "affordances": "{path}", "repos": ["{repo-card-name}"] }
|
|
338
|
+
]
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`products` is optional: a team adopting Portulan for one repository need not name a product at all, and
|
|
342
|
+
the engine still resolves everything else. Note the distinction that keeps this honest — the *definition*
|
|
343
|
+
has a product-layer slot, which is what the criterion requires; *instances* may leave it empty.
|
|
344
|
+
|
|
345
|
+
**What `doctor` checks:** each `product` path resolves; each `affordances` path **that is present**
|
|
346
|
+
resolves; each `repos` entry names a card in the `repos` slot. Per-product `affordances` is optional
|
|
347
|
+
because a product may inherit the workspace-level default — so the check is conditional, and `doctor`
|
|
348
|
+
*reports* a product that has neither its own nor an inherited one rather than failing it. (An earlier
|
|
349
|
+
draft of this line said `doctor` checks `affordances` unconditionally, which would have made the spec
|
|
350
|
+
demand something the schema declares optional; caught in review of the change that introduced it.) The
|
|
351
|
+
claim it cannot check is whether the mission written there is still true.
|
|
352
|
+
|
|
353
|
+
## `affordances` — what the product offers an agent
|
|
354
|
+
|
|
355
|
+
The unclaimed niche in the influence map: *agent-legibility, repo affordances scored by `doctor`*. The
|
|
356
|
+
slot holds what an agent can rely on — entry points, contracts, budgets, commands — **and** what it must
|
|
357
|
+
not assume, which is the half that makes the document worth reading. Customer zero's
|
|
358
|
+
[affordances document](../.portulan/products/portulan/affordances.md) is a filled example.
|
|
359
|
+
|
|
360
|
+
It resolves down the cascade *inside* the workspace: a workspace-level `affordances` is the default, and
|
|
361
|
+
a product may override it. That is thesis 1's more-specific-wins rule applied one level lower, and it
|
|
362
|
+
exists because a portfolio workspace covering a Markdown framework and a deployed service cannot offer
|
|
363
|
+
one honest answer for both.
|
|
364
|
+
|
|
365
|
+
**What `doctor` checks:** the path resolves — **and, since milestone 7 session 7, it opens the document
|
|
366
|
+
and scores it.** The agent-legibility score named in the vision's delivery tiers is built: **seven**
|
|
367
|
+
dimensions, of which two read this slot — that every product has a document reachable here (its own, or
|
|
368
|
+
the workspace-level default above), and that each such document names what an agent must **not** assume.
|
|
369
|
+
The other five read the manifest, because a score confined to this slot could not discriminate between
|
|
370
|
+
two workspaces at all; **this slot is the score's named input rather than its only one**. Two limits
|
|
371
|
+
worth having in front of you: the score **moves no exit code**, since a measurement that could fail a
|
|
372
|
+
workspace would make conformance a function of how much prose somebody wrote; and the *must not assume*
|
|
373
|
+
dimension is a **form** check over a named table of headings, so a section with an empty body passes it.
|
|
374
|
+
The claim it still cannot check is whether what the document promises is true. One memory entry
|
|
375
|
+
([`readme-map-must-match-shape.md`](../.portulan/memory/readme-map-must-match-shape.md)) records its own
|
|
376
|
+
retirement as conditional on that score superseding the README map — worth stating plainly that this
|
|
377
|
+
milestone does **not** fire that condition.
|
|
378
|
+
|
|
379
|
+
## `tree` — where the claims are checked against, and why it is declared rather than inferred
|
|
380
|
+
|
|
381
|
+
Added in v2.0, slot and requirement together (an optional-slot draft never reached `main`), and it
|
|
382
|
+
exists because the claims lint needed an answer to a question nobody had asked while there was only one
|
|
383
|
+
workspace: *which* tree does a repo card's `test:` line refer to?
|
|
384
|
+
|
|
385
|
+
Customer zero's answer is obvious — the repository the workspace sits in, one level up. The demo's
|
|
386
|
+
answer is that there isn't one: Rooftop's repositories are fictional, so its cards' build/test/run lines
|
|
387
|
+
and layout point at directories no tree contains. Both must validate, and a lint that failed the second
|
|
388
|
+
would be wrong about it rather than right.
|
|
389
|
+
|
|
390
|
+
The tempting inference is `kind`: check for `repository`, skip for `demo`. It was rejected for two
|
|
391
|
+
reasons, and the second is the stronger one.
|
|
392
|
+
|
|
393
|
+
- **It breaks on its own second case.** A `portfolio` workspace covers many repositories, none of which
|
|
394
|
+
is the one it sits in. It is not a demo, and it has no single tree either — so kind-dispatch would
|
|
395
|
+
demand a lint it cannot run, one milestone after v1.
|
|
396
|
+
- **It disables a check class on a self-declared field.** `kind` is typed by an author, and `doctor`
|
|
397
|
+
checks only that it is one of the permitted values. A whole category of checking that switches itself off
|
|
398
|
+
because somebody wrote `demo` is a fail-open with a doorbell on it — this repository has now recorded
|
|
399
|
+
three of that shape ([`../.portulan/memory/verify-preconditions-fail-closed.md`](../.portulan/memory/verify-preconditions-fail-closed.md)),
|
|
400
|
+
every one of them in gate machinery.
|
|
401
|
+
|
|
402
|
+
So the workspace declares it. Present means *lint these claims against that tree*; absent means *these
|
|
403
|
+
claims describe something not present here*.
|
|
404
|
+
|
|
405
|
+
**And a `repository` workspace must declare it** — spec 2.0, from
|
|
406
|
+
[proposal 0005](../.portulan/proposals/0005-a-repository-workspace-must-declare-its-tree.md). Optional
|
|
407
|
+
everywhere turned out to be a hole: deleting one line degraded the whole claims-lint class to notes,
|
|
408
|
+
GREEN, exit 0. A `repository` workspace *is* the policy layer of a repository that is present, so it
|
|
409
|
+
always has an answer; `demo` and `portfolio` genuinely do not, and stay exempt.
|
|
410
|
+
|
|
411
|
+
**This was the first rule here the schema does not carry, and that is a real cost.** A conditional
|
|
412
|
+
dependency between two keys needs `if`/`then` or `dependentRequired`, and neither is in the subset
|
|
413
|
+
[`README.md`](README.md) declares — so the constraint lives in `doctor` as a cross-field check, invisible
|
|
414
|
+
to anyone reading the schema alone. Stated loudly rather than hidden, because "read the schema to know
|
|
415
|
+
the contract" is otherwise false in these places, and an unmarked exception is worse than a marked one.
|
|
416
|
+
|
|
417
|
+
**It is now one of nine**, and [`README.md`](README.md) keeps the running count because the number is
|
|
418
|
+
the thing to watch. Two arrived with `memory` at 2.3 — a declared `memory` object needs a
|
|
419
|
+
`slots.memory` store to index, and `memory.index.path` must resolve *outside* it — two more with
|
|
420
|
+
`librarian` at 2.4, and then that same store-and-siting pair twice again: over the handoff series at 2.5
|
|
421
|
+
and over the per-persona scope layer at 2.6. The two `librarian` brought:
|
|
422
|
+
|
|
423
|
+
- **`librarian` requires `slots.memory`.** There is nothing to age otherwise, and a pass over an absent
|
|
424
|
+
store reports that nothing is stale, which is indistinguishable from a healthy one.
|
|
425
|
+
- **`librarian.staleness.proposal_days` requires `slots.proposals`.** A threshold nothing can ever
|
|
426
|
+
cross reads as configured to anyone who greps for it. The pass itself reports *not asked* in that
|
|
427
|
+
case, which is deliberately not the same answer as *none pending* — but a policy that can only ever
|
|
428
|
+
print *not asked* is a policy nobody meant to write.
|
|
429
|
+
|
|
430
|
+
**What `doctor` checks:** the path resolves to a directory; and that a `repository` workspace has one.
|
|
431
|
+
With `tree` present, every path-shaped claim
|
|
432
|
+
in a repo card must exist, and the gate map's required-check claim must match a workflow job. With
|
|
433
|
+
`tree` absent, those claims are counted and **reported unverifiable** — never skipped silently, because
|
|
434
|
+
the whole point is that a check which vanishes without saying so is worse than one that admits it could
|
|
435
|
+
not run.
|
|
436
|
+
|
|
437
|
+
Three limits worth stating before somebody relies on this. A claim is resolved against **either** the tree
|
|
438
|
+
root or the card's own directory, because a real card mixes the two bases in one line — customer zero's
|
|
439
|
+
does — and a lint that insisted on one would produce false reds. Only tokens that look like paths are
|
|
440
|
+
checked: something containing `/`, never absolute, taken from a code span or a link target. `build: none`
|
|
441
|
+
claims nothing. Prose outside the two parsed sections is left alone rather than guessed at, because an
|
|
442
|
+
ambitious parser is the shortest route to the false red that gets a whole recipe switched off.
|
|
443
|
+
|
|
444
|
+
**Severity is where that lesson actually bit**, and the rule is worth knowing before writing a card. A
|
|
445
|
+
build/test/run candidate that is a **single path-shaped token** — `./verify.sh` — is an unambiguous claim
|
|
446
|
+
and **fails** when absent. A candidate that is a **command** — `dotnet run --project src/App` — only
|
|
447
|
+
*contains* tokens that might be paths, and those are **reported, never failed**, because nothing can
|
|
448
|
+
distinguish an input path from an output path not built yet, a flag value, a `sed` expression, a glob or
|
|
449
|
+
an npm script name. A line with nothing path-shaped in it is counted and reported too. Nothing is
|
|
450
|
+
dropped in silence — which it was, until the third real workspace's card exposed it — but the first
|
|
451
|
+
attempt at fixing that failed command tokens outright and produced false reds on `go test ./...`,
|
|
452
|
+
`cc -o bin/app src/main.c` and `--project=src/App` with the directory present, which is precisely the
|
|
453
|
+
shape [`../core/templates/repo-card.md`](../core/templates/repo-card.md) tells adopters to write.
|
|
454
|
+
|
|
455
|
+
And the third: **claims resolve against the filesystem, not against git.** A card naming a directory that
|
|
456
|
+
`.gitignore` excludes — a runtime `state/` or `logs/` — resolves in a working copy where the application
|
|
457
|
+
created it and is absent from a clean checkout. The failure direction is local-green / CI-red, which is
|
|
458
|
+
fail-closed and therefore the safe one, but it means a local green is the weaker of the two.
|
|
459
|
+
|
|
460
|
+
## `packs` — the cascade's missing middle
|
|
461
|
+
|
|
462
|
+
Nothing in the criterion asks for this, and it is included anyway, which needs justifying. The cascade is
|
|
463
|
+
`core < pack < workspace < repo card < task`; every layer but one is addressable from a manifest, and a
|
|
464
|
+
workspace that cannot say which packs it composes leaves an agent to infer the middle of its own
|
|
465
|
+
resolution order. The portfolio workspace at milestone 6 ships as a private-feed plugin composing a pack
|
|
466
|
+
delivered by that feed — a manifest that cannot express that forces a MAJOR bump one milestone after v1.
|
|
467
|
+
|
|
468
|
+
_(This sentence said **premium** packs until milestone 6, session 1, and no shipped pack is premium: the
|
|
469
|
+
one the portfolio workspace composes is the universal checkpoint ritual, delivered by the private feed and
|
|
470
|
+
authored in the public engine. The prediction was reasonable and turned out not to be what milestone 6
|
|
471
|
+
built, so it is corrected to what the tree shows rather than left as a promise about a pack nobody wrote.
|
|
472
|
+
Whether a premium pack should exist at all is a commercial question for the maintainer, not a gap this
|
|
473
|
+
file may quietly keep open.)_
|
|
474
|
+
|
|
475
|
+
An array of names in cascade order, empty by default. Each name is the canonical `category/name` form
|
|
476
|
+
the Pack Definition owns ([`pack.schema.json`](pack.schema.json)) — but the items stay **free strings**
|
|
477
|
+
here, deliberately. Tightening them to that shape would be a constraint a manifest could newly fail,
|
|
478
|
+
which is a MAJOR bump, and the two schemas are on separate version trains precisely so neither forces
|
|
479
|
+
the other's hand.
|
|
480
|
+
|
|
481
|
+
**What `doctor` checks**, as of milestone 6: that the names are unique, that each **resolves** to a
|
|
482
|
+
`pack.json` under a resolution root, and that the manifest it finds **validates against the Pack
|
|
483
|
+
Definition**. It reports what each pack contributes. Resolution roots are derived from `tree` rather
|
|
484
|
+
than declared — a slot before its consumer is the mistake this specification was written to avoid — so
|
|
485
|
+
a workspace with no `tree` has nowhere to search and its declared packs are reported *unverifiable*
|
|
486
|
+
rather than failed, which is the same answer `tree`'s absence already gives every other claim that
|
|
487
|
+
needs one.
|
|
488
|
+
|
|
489
|
+
**What is still not demonstrated is resolution from a FEED.** The roots searched today are the
|
|
490
|
+
workspace's own tree; an adopter installing a pack from a private marketplace resolves inside the
|
|
491
|
+
installed plugin instead. The resolver takes its roots as an argument for exactly that reason — so the
|
|
492
|
+
feed case is the same code path rather than a parallel one — but it has not yet been run that way, and
|
|
493
|
+
this sentence is what says so.
|
|
494
|
+
|
|
495
|
+
## `memory` — what the store's index is called, and what memory may cost
|
|
496
|
+
|
|
497
|
+
Added at 2.3. `slots.memory` is the store; this object is the machine configuration around it — the
|
|
498
|
+
same split as `slots.gates` (the argument, in prose) and `gates` (the policy, compiled), and for the
|
|
499
|
+
same reason: one is content a human reads, the other is fields a tool dispatches on.
|
|
500
|
+
|
|
501
|
+
It exists because [`memory.md`](../core/operating/memory.md) has described a *generated, size-budgeted*
|
|
502
|
+
index since milestone 1 and neither adjective had a machine behind it. The index is emitted by
|
|
503
|
+
[`../cli/index.mjs`](../cli/index.mjs), committed, and byte-compared by a verify recipe — generated so
|
|
504
|
+
the store is the single source, committed so a change to what is always loaded is reviewable in a diff.
|
|
505
|
+
|
|
506
|
+
| Field | What it is |
|
|
507
|
+
|---|---|
|
|
508
|
+
| `index.path` | Where the generated index is written. Must resolve **outside** `slots.memory` — see below. |
|
|
509
|
+
| `index.budget.lines` | The most lines the index may hold. One line per record, so this is a rail on record count. |
|
|
510
|
+
| `index.budget.columns` | The most columns one line may hold — refused, never truncated. |
|
|
511
|
+
| `store.budget.kilobytes` | The most the store's records may total, in 1024-byte KB. |
|
|
512
|
+
| `store.budget.record_kilobytes` | Added at 2.8. The most any **one** record may hold. |
|
|
513
|
+
|
|
514
|
+
**Four numbers rather than one, because they are different axes — and each pairs with a per-unit cap
|
|
515
|
+
that closes the hole the aggregate has.** The index is what gets loaded to decide what else to load,
|
|
516
|
+
so its line count is what memory costs on every recall. It cannot see the other axis at all: a store
|
|
517
|
+
whose record count never moves can grow without limit in bytes, and nothing in the index would change.
|
|
518
|
+
Then each aggregate has the same blind spot inside its own units. The `columns` cap closes it for
|
|
519
|
+
`lines` — one enormous line absorbing what the budget counts. **`record_kilobytes` closes it for
|
|
520
|
+
`kilobytes`**, and it is the same sentence one level down: one enormous record absorbs what the store
|
|
521
|
+
budget counts. Added at 2.8 with proposal `0025`, after the aggregate ran to 98% while the record that
|
|
522
|
+
drove it there had never once felt the rail.
|
|
523
|
+
|
|
524
|
+
**The per-record cap also fixes where a breach lands, which is the half a bigger number could not
|
|
525
|
+
buy.** An aggregate over individually-authored records is a **commons**. The record that grew to 15.7
|
|
526
|
+
KB never breached anything at write time; three unrelated changes hit the total afterwards, one with
|
|
527
|
+
551 bytes of headroom. Under a per-record cap the breach is **local** — record X over its cap never
|
|
528
|
+
blocks writer Y — and it fires on the author growing the record, at the moment of growth. The two
|
|
529
|
+
forms stay independent and a workspace may declare either, both, or neither: this repository's own
|
|
530
|
+
workspace declares `record_kilobytes` alone, and [`../examples/workspace.json`](../examples/workspace.json)
|
|
531
|
+
declares `kilobytes`, so both rails have a live carrier in the tree rather than one of them being a
|
|
532
|
+
key the schema accepts and nothing exercises.
|
|
533
|
+
|
|
534
|
+
**Nothing is defaulted**, on the `floor` object's rule from 2.2: a default here would be this
|
|
535
|
+
specification setting a policy for every workspace that ever adopts it, in a key nobody typed. An
|
|
536
|
+
undeclared number is not checked. A number that is declared and is not a positive integer is
|
|
537
|
+
**refused** — `lines: 0` would otherwise read as falsy, switching the rail off in the key that exists
|
|
538
|
+
to switch it on, and the subset has no `minimum` with which to say so in the schema.
|
|
539
|
+
|
|
540
|
+
**What `doctor` checks:** that `index.path` resolves; that `slots.memory` is present whenever this
|
|
541
|
+
object is; and that `index.path` does **not** resolve inside `slots.memory`. The last is the one worth
|
|
542
|
+
arguing. `doctor`'s store report walks every `.md` in the store, so an index living there is counted as
|
|
543
|
+
a record, sized into the KB figure, and reported for stating no retirement condition — a report about
|
|
544
|
+
the store that includes a file the store does not hold. The alternative repair was to exempt the
|
|
545
|
+
index's filename from the walk, and that was rejected: an exemption by name is a door any record could
|
|
546
|
+
walk through, and this repository had found eight fail-opens of that shape in its own scaffolding. A
|
|
547
|
+
siting rule has no such door — though the rule still has to be *tested* correctly, and the first cut of
|
|
548
|
+
this one was the ninth: it read a leading `..` in a **filename** as a traversal, so `memory/..index.md`
|
|
549
|
+
was judged outside the store it plainly sits in, written there, and counted as a record. The door was
|
|
550
|
+
not in the design; it was in the containment test.
|
|
551
|
+
|
|
552
|
+
**What no checker establishes:** that a budget was not simply raised in the change that breached it.
|
|
553
|
+
That rule is [`memory.md`](../core/operating/memory.md)'s and the human gate's — refusing a raise needs
|
|
554
|
+
a check that reads git history, which produces false reds in a shallow CI checkout. The limit is
|
|
555
|
+
recorded in [`../.portulan/verify/README.md`](../.portulan/verify/README.md) with the measurement
|
|
556
|
+
behind it, rather than left for a reader to assume the rail covers both halves.
|
|
557
|
+
|
|
558
|
+
## `handoffs` — an index over a series nobody can consolidate
|
|
559
|
+
|
|
560
|
+
Added at 2.5, and it is deliberately one key. `slots.handoffs` is the series; this object says where
|
|
561
|
+
its generated index goes. The same split as `memory` above, built by the same generator, byte-compared
|
|
562
|
+
by the same recipe.
|
|
563
|
+
|
|
564
|
+
| Field | What it is |
|
|
565
|
+
|---|---|
|
|
566
|
+
| `index.path` | Where the generated index is written. Must resolve **outside** `slots.handoffs` — see below. |
|
|
567
|
+
|
|
568
|
+
`index` is **required whenever `handoffs` is present**, which is the one way this object differs from
|
|
569
|
+
`memory`. There an object with no `index` is coherent: a workspace may rail its store's size and
|
|
570
|
+
generate nothing. Here there is no budget and exactly one key, so `handoffs: {}` configures nothing —
|
|
571
|
+
a no-op that reads as configured to anyone who greps for it, the same failure
|
|
572
|
+
`librarian.staleness.proposal_days` without `slots.proposals` is refused for. Unlike the two
|
|
573
|
+
conditional requirements below, this one *is* expressible in the declared subset, so the schema
|
|
574
|
+
carries it rather than `doctor`.
|
|
575
|
+
|
|
576
|
+
**Why it exists.** [`loop.md`](../core/operating/loop.md) has said since the handoff-cadence rule landed
|
|
577
|
+
that *"the librarian that mines the series is milestone 5"*, and the series had no index at all while
|
|
578
|
+
the store it sits beside had one. Measured on this repository the day the milestone-5 row was amended:
|
|
579
|
+
the handoff series was **3.4×** the memory store. The layer with the index was the small one.
|
|
580
|
+
|
|
581
|
+
**Every field on a line is derived, and the two carriers differ from the store's on purpose.** A
|
|
582
|
+
record's title is its **filename** — [`memory-entry.md`](../core/templates/memory-entry.md) prescribes
|
|
583
|
+
no heading, and most records carry none, so the filename is the only title every record has. A
|
|
584
|
+
handoff's title is its **H1**: the filename leads with an ISO date and continues as a slug, so a
|
|
585
|
+
filename-derived title reads `2026 07 28 the librarian goes on a cron` — a string no reader navigates
|
|
586
|
+
by and no cross-reference uses. Every handoff in this repository carries an H1, so the heading is the
|
|
587
|
+
carrier that actually exists here, and one that does not is **failed** rather than titled from its
|
|
588
|
+
filename: a generated line whose title the generator invented is the one thing a generated file must
|
|
589
|
+
not contain. The date is derived from the filename, which is where the cadence rule already puts it.
|
|
590
|
+
|
|
591
|
+
**Its siting rule has two enforcers rather than one, and neither is redundant.** An index inside
|
|
592
|
+
`slots.handoffs` is either counted as a handoff by `docs.sh`'s date-correspondence check — inflating
|
|
593
|
+
one side of a count the Session log is held to — or, carrying no date, failed by the same check for
|
|
594
|
+
exactly that. `index` refuses the siting outright, because it cannot render a series it would then be
|
|
595
|
+
a member of. The second enforcer is not a second opinion: the generator physically cannot proceed,
|
|
596
|
+
while the recipe is what catches a hand-placed file the generator never saw.
|
|
597
|
+
|
|
598
|
+
**There is no budget here, and the absence is the argued half.** A budget's only permitted remedy is
|
|
599
|
+
consolidation — merge, compress, split, retire ([`memory.md`](../core/operating/memory.md)) — and a
|
|
600
|
+
handoff series is append-only by construction: one per session, dated, held to the Session log by a
|
|
601
|
+
count-based correspondence. Retiring a handoff to buy headroom would either red that check or destroy
|
|
602
|
+
the record it exists to keep; **splitting one** — the move 2.8 added for the per-record rail — would
|
|
603
|
+
put two dated handoffs against a single session and red the same correspondence from the other side;
|
|
604
|
+
and raising the budget in the change that breached it is the one repair that doctrine rules out. Every
|
|
605
|
+
remedy such a budget could ask for is barred, which makes it a rail designed to be broken — and a rail
|
|
606
|
+
that fires with no legal repair is how a whole recipe gets switched off.
|
|
607
|
+
|
|
608
|
+
_That test is the one the store's own aggregate byte budget later failed — which is why **this
|
|
609
|
+
repository's workspace** swapped `kilobytes` for the per-record cap at 2.8 rather than widening it. The
|
|
610
|
+
definition replaced nothing: both keys stay legal and a workspace may declare either. The argument this
|
|
611
|
+
paragraph makes about handoffs is the argument `0025` makes about `kilobytes`, and it was written down
|
|
612
|
+
before the case arrived._ Whether
|
|
613
|
+
the series should be railed on some other axis is a **maintainer's question**, deferred to the
|
|
614
|
+
reconciliation that follows milestone 5 rather than pre-answered here in the shape of an unused key.
|
|
615
|
+
|
|
616
|
+
**What the pass reads that this object does not configure.** Record ages come from git for both series,
|
|
617
|
+
and the handoff series is **reported** — count, oldest, total size — with no threshold and no demotion
|
|
618
|
+
draft. That is not an omission: `librarian.staleness.record_days` drafts a demotion, and drafting one
|
|
619
|
+
against an append-only series would recommend the single repair the paragraph above rules out, weekly,
|
|
620
|
+
forever.
|
|
621
|
+
|
|
622
|
+
## `personas` — where a pack-declared memory scope lands, and how anyone can tell it did
|
|
623
|
+
|
|
624
|
+
Added at **2.6**, as a pair: `slots.personas` is the adopter's per-persona layer, one directory per
|
|
625
|
+
persona whose memory scope a composed pack declares, and `personas.index.path` is the generated artifact
|
|
626
|
+
over it. The same split as `memory` and `handoffs`, built by the same generator, byte-compared by the same
|
|
627
|
+
recipe.
|
|
628
|
+
|
|
629
|
+
| Field | What it is |
|
|
630
|
+
|---|---|
|
|
631
|
+
| `slots.personas` | The layer. One directory per declared scope, each **empty until earned**. |
|
|
632
|
+
| `personas.index.path` | Where the generated index goes. Must resolve **outside** `slots.personas`. |
|
|
633
|
+
|
|
634
|
+
**Why it exists.** [`memory.md`](../core/operating/memory.md) has said since milestone 1 that memory is
|
|
635
|
+
per-agent rather than global, and until milestone 6 nothing in this project had a per-agent store: every
|
|
636
|
+
store shipped was a *workspace's*, shared by whatever agent read it. A **pack** is the first artifact this
|
|
637
|
+
framework distributes that carries per-persona material its adopter does not own, which is the first point
|
|
638
|
+
at which *whose* memory this is has an answer that matters — the maintainer's ruling of 2026-07-29,
|
|
639
|
+
verbatim, *"row 6 declares, row 7 validates"*.
|
|
640
|
+
|
|
641
|
+
**This series' source is the cascade, not the tree — the only one of the three.** The memory store and the
|
|
642
|
+
handoff series are directories the workspace owns and the generator walks. A scope is declared by a persona
|
|
643
|
+
*inside a pack the workspace merely composes*, so finding it means resolving `packs` the way `compile` and
|
|
644
|
+
`doctor` already resolve it. One consequence is worth stating because it looks like a bug the first time:
|
|
645
|
+
a workspace can be handed a resolution root on the command line (`--pack-root`), so the same workspace
|
|
646
|
+
renders the same index whether the pack came from beside it or from a private feed. Every field on a line
|
|
647
|
+
is content-derived, so the artifact is **path-independent** — measured at milestone 6, session 1, where
|
|
648
|
+
customer zero and a feed-installed adopter produced the identical scope digest.
|
|
649
|
+
|
|
650
|
+
**Declared rather than derived from `slots.memory`, and both halves of that are the argument.** Not
|
|
651
|
+
derived, because a path this specification computed would be Portulan choosing a location inside every
|
|
652
|
+
adopter's workspace in a key nobody typed — the rule every memory budget here already obeys. And not a
|
|
653
|
+
subdirectory of the store, for two reasons that point the same way: doctrine holds per-agent memory apart
|
|
654
|
+
precisely so a reviewer's recall does not spend the implementer's budget, and a store nested inside the
|
|
655
|
+
store would be counted by the `kilobytes` rail while being invisible to the flat walk that counts it —
|
|
656
|
+
[issue #76](https://github.com/sleepy-panda-srl/portulan/issues/76). Siting the layer outside leaves that
|
|
657
|
+
issue open to be decided on its own merits instead of settled as a side effect of this one.
|
|
658
|
+
|
|
659
|
+
**A location is literally empty, and that is a ruling rather than a reading.** *"Present and empty"* could
|
|
660
|
+
mean *empty of records* — a marker file inside stating whose scope it is — and the maintainer ruled on
|
|
661
|
+
2026-07-30 that it binds literally. Which has a consequence the design leans on: **git does not record an
|
|
662
|
+
empty directory**, so the location cannot travel in a plugin payload at all. The declaration travels in the
|
|
663
|
+
index; the directory is created in the adopter's own layer when the adopter runs the landing. An absent
|
|
664
|
+
location is therefore never a finding — it is the state of every fresh clone.
|
|
665
|
+
|
|
666
|
+
**Two controls, and the second one is what makes the first mean anything.** The index is the *positive*
|
|
667
|
+
control: every field on a line is derived from the pack, including a digest over the declared scope's own
|
|
668
|
+
text, so a pack that rewords a scope moves this file and the byte comparison tells the adopter. A first
|
|
669
|
+
sentence alone would have let a pack reword everything after it invisibly. The *negative* control is a
|
|
670
|
+
sweep for **anything** under the layer that no composed persona declares — a stray file as much as a stray
|
|
671
|
+
directory, since the first implementation tested only directories and let a `.md` dropped in pass in silence.
|
|
672
|
+
Without it the layer would accept anything and the positive control would only ever examine locations it
|
|
673
|
+
already expected, which is
|
|
674
|
+
[`a-manifest-field-can-validate-and-load-nothing`](../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md)
|
|
675
|
+
wearing this feature's clothes. A third check runs at the *distributing* side: a pack shipping memory
|
|
676
|
+
records of its own is refused, because storage follows ownership and a pack absorbing an adopter's records
|
|
677
|
+
is the constitution's thesis 6 violated from the direction the adopter cannot see.
|
|
678
|
+
|
|
679
|
+
**What no part of this reads, said plainly.** Nothing recalls from these locations, nothing consolidates
|
|
680
|
+
them, and no budget rails them. `doctor` validates a persona against its five-part contract **as of
|
|
681
|
+
milestone 7 session 2** — that is the *validates* half of the ruling above, and it arrived rather than
|
|
682
|
+
being promised. What it validates is the **presence** of the five parts, including a `## Memory scope`
|
|
683
|
+
section; it does not read these locations or judge what a scope contains, which is why this paragraph's
|
|
684
|
+
heading still holds. There is no budget for the same reason:
|
|
685
|
+
there is nothing yet to budget, and the axis such a rail should use is per-persona rather than
|
|
686
|
+
per-workspace, which belongs to the row where something finally reads these locations.
|
|
687
|
+
|
|
688
|
+
## `provenance` — a record field, not a manifest key
|
|
689
|
+
|
|
690
|
+
The criterion lists a provenance slot beside the others, but it does not belong in the manifest, and
|
|
691
|
+
saying why is more useful than quietly filing it somewhere. Provenance is a property of **a rule**, and
|
|
692
|
+
rules do not live in the manifest — they live in memory entries and proposals, which are Markdown. A
|
|
693
|
+
manifest key would have described a workspace's *policy about* provenance while leaving every actual
|
|
694
|
+
rule unchecked.
|
|
695
|
+
|
|
696
|
+
So the schema defines the **shape** in `$defs/provenance` and the records carry instances of it:
|
|
697
|
+
one definition, two carriers. `doctor` parses a record's `provenance:` field into that shape and
|
|
698
|
+
validates it.
|
|
699
|
+
|
|
700
|
+
Adopted 2026-07-25 by the maintainer from
|
|
701
|
+
[proposal 0002](../.portulan/proposals/0002-sealed-provenance.md). Two forms, one of them mandatory:
|
|
702
|
+
|
|
703
|
+
```
|
|
704
|
+
provenance: form=link href={ticket | PR | postmortem | commit}
|
|
705
|
+
provenance: form=sealed owner={who can re-validate} date={YYYY-MM-DD} shape={the de-identified failure}
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
**Why two.** Thesis 4 requires every rule to link to the incident that created it; thesis 6 requires a
|
|
709
|
+
team's specifics to stay in the layer their owner controls. A rule generalised upward out of a private
|
|
710
|
+
incident collides with both — it must either carry a link it is not allowed to hold, or drop provenance
|
|
711
|
+
and become un-retirable. The sealed form is the declared-weak option: the stamp and the mechanism travel,
|
|
712
|
+
the episode does not.
|
|
713
|
+
|
|
714
|
+
**Honest limits, carried from the proposal rather than lost in adoption:**
|
|
715
|
+
|
|
716
|
+
- **The machine checks the stamp's form, never its truth.** A fabricated sealed stamp passes exactly as a
|
|
717
|
+
real one does. What guards the content is the *generic must never decay into vague* bar in
|
|
718
|
+
[`codify` step 1](../core/skills/codify/SKILL.md) plus human review — the machine catches absence, the
|
|
719
|
+
human judges substance.
|
|
720
|
+
- **"Resolvable" means well-formed, not fetched.** `doctor` validates a link's shape and does not
|
|
721
|
+
dereference it. A gate that needs the network fails for reasons unrelated to the change under test, and
|
|
722
|
+
a flaky gate is worse than no gate — this workspace already refuses network-dependent checks.
|
|
723
|
+
- **A sealed rule is not as good as a linked one.** It is *declared* weak instead of silently absent.
|
|
724
|
+
`doctor` therefore reports the **sealed proportion**: a workspace where everything is sealed has
|
|
725
|
+
quietly opted out of retirement altogether, and that is a health signal rather than a curiosity.
|
|
726
|
+
- **Adopting this was a constitutional interpretation**, and the constitution has since been changed to
|
|
727
|
+
match. It decides how thesis 4's "links to the incident" reads in the collision case; the maintainer
|
|
728
|
+
accepted it, reserved the matching wording change in [`../docs/vision.md`](../docs/vision.md) to his
|
|
729
|
+
own hand, and made it. Thesis 4 now names both forms itself, so the machine that enforces the two-form
|
|
730
|
+
shape is enforcing something the constitution states — which is the only order these two are allowed to
|
|
731
|
+
be in. _(For a window between the two changes the constitution read "links" while this spec permitted a
|
|
732
|
+
stamp. That gap was recorded here rather than smoothed over, and closed before `doctor` shipped, which
|
|
733
|
+
is what the proposal required: a rule mechanically enforced by tooling and absent from the constitution
|
|
734
|
+
is backwards for this product.)_
|
|
735
|
+
|
|
736
|
+
- **It binds a rule, and only a rule.** `doctor` requires a two-form stamp on `type: rule` records in the
|
|
737
|
+
`memory` slot and nowhere else. A `decision`, `reference`, or `glossary` record whose provenance parses
|
|
738
|
+
as neither form is **reported**, never failed — thesis 4, this proposal as adopted, `dod.md` condition
|
|
739
|
+
3, and the milestone's own criterion are all rule-scoped, and having the validator bind types nobody
|
|
740
|
+
legislated for would be the same inversion as the previous bullet. **Proposals are not checked at all**
|
|
741
|
+
in v1, though a proposal argues for a rule and carries a `Provenance` section of its own. Two reasons,
|
|
742
|
+
and the second is the one that decides it. A proposal is the *argument* for a rule; the rule itself
|
|
743
|
+
lands in a memory entry or in `core/` when accepted, and that is the carrier the mandate names — a
|
|
744
|
+
proposal's provenance documents where the reasoning came from, which is a different thing from the
|
|
745
|
+
incident stamp `$defs/provenance` describes. And at any moment a proposal can be sitting
|
|
746
|
+
**pending** the human gate, so a checker demanding a format from it would be acting on a record
|
|
747
|
+
mid-decision, which is the gate's business and not a validator's. Saying which carrier is unchecked
|
|
748
|
+
matters more than the choice does — silence is how the next reader assumes coverage that is not there.
|
|
749
|
+
|
|
750
|
+
- **`type` is self-declared, and that is an opening.** A rule labelled `decision` walks past the check.
|
|
751
|
+
Closing it means either inferring type from content or extending the mandate, and the first is
|
|
752
|
+
guesswork while the second is the maintainer's to legislate, not the validator's to assume.
|
|
753
|
+
|
|
754
|
+
## Considered and left out
|
|
755
|
+
|
|
756
|
+
Each of these was a candidate; none is an oversight.
|
|
757
|
+
|
|
758
|
+
- **`rituals` as its own slot.** The constitution names rituals as a pack category, and — when this was
|
|
759
|
+
written — also inside thesis 1's workspace list. The two readings disagreed and the schema had to pick
|
|
760
|
+
one. It picks packs: rituals ship as packs ([`../packs/rituals/`](../packs/rituals/)), so a workspace
|
|
761
|
+
composes them through `packs`, and a second slot would be two ways to say one thing. **The ambiguity
|
|
762
|
+
was raised with the maintainer rather than settled here** — the constitution is human-owned and a
|
|
763
|
+
schema is not the place to decide what it means — and he has since settled it: thesis 1 now lists
|
|
764
|
+
rituals once, under packs, which is the reading this schema had guessed at. Kept rather than deleted,
|
|
765
|
+
because the record of *how* the disagreement was resolved is the part worth having: the schema deferred
|
|
766
|
+
and the human legislated. Revisit if a workspace-local ritual ever needs a home that is not a pack.
|
|
767
|
+
- **`stack` and `glossary` as slots.** Both are named in thesis 1 and both live inside `identity.md`
|
|
768
|
+
today. Splitting them buys nothing until something *consumes* them — a stack pack selecting itself
|
|
769
|
+
from a declared stack would be that consumer, and it does not exist. Splitting on speculation is how a
|
|
770
|
+
schema acquires slots nobody fills.
|
|
771
|
+
- **A sealed-owner registry.** Sealed stamps name an owner as free text; a workspace-level list of valid
|
|
772
|
+
owners would let `doctor` catch typos. Deferred: it adds a required-to-maintain list for a check that
|
|
773
|
+
catches a mild failure. The librarian's nagging is the mechanism that would actually need it, and as
|
|
774
|
+
of milestone 5 it exists and reads the owner as free text — so this is a deferral with a live consumer
|
|
775
|
+
rather than a speculative one, and the first typo'd owner is the incident that would settle it.
|
|
776
|
+
- **A per-host capability matrix.** The vision's `doctor` includes a per-host capability report, but that
|
|
777
|
+
is generated from the host and the compiled gates, not declared by the team. It belongs with the
|
|
778
|
+
enforcement backends in milestone 4.
|
|
779
|
+
- **`evals`.** Milestone 8 owns the eval harness; a slot pointing at golden tasks before any exist would
|
|
780
|
+
be the emptiest kind of scaffolding.
|
|
781
|
+
|
|
782
|
+
## What v2.0 is not
|
|
783
|
+
|
|
784
|
+
- **It has been validated against exactly two instances**, one of which it was derived from. The demo in
|
|
785
|
+
[`../examples/`](../examples/) was the real test, and it did what a second instance is supposed to do:
|
|
786
|
+
it produced a schema change (`tree`) within an hour of being written. A third differently-shaped
|
|
787
|
+
workspace — the portfolio one at milestone 6 — should be expected to produce another.
|
|
788
|
+
- **`doctor` checks form, not truth.** Every line above says what it resolves, parses, or matches. It
|
|
789
|
+
cannot tell whether a mission statement is still accurate, whether a sealed stamp describes a real
|
|
790
|
+
incident, whether a gate map's tiers are honoured, or whether a `requires` list is complete. The
|
|
791
|
+
machine catches absence; the human judges substance.
|
|
792
|
+
- **It never executes anything.** Recipes are read and never run. The Stop-gate runner (milestone 4)
|
|
793
|
+
executes the **default** recipe, so that one is tested by being run; every other declared recipe is
|
|
794
|
+
still read only, and one quietly needing an undeclared tool still passes.
|
|
795
|
+
- **Nothing here describes how a workspace is *installed*.** Workspaces ship as plugins through a feed;
|
|
796
|
+
that packaging is milestone 3 for the public path and milestone 6 for the private one.
|