git-subtree-dir: vendor/trainer git-subtree-mainline:c7c2956e15git-subtree-split:a2a9f4edcf
Vestmark kommune - Building and Planning, 2026-09-25
The hardest sector the trainer has been given, chosen to find where portal stops rather than to flatter it: a municipal building authority. Applications that pause and resume, a decision no case officer may sign, a group outside the building that moves records and nothing else, objections that belong to other cases, a six-week deadline nobody presses a button for, and an appeal that reopens something already finished.
Built with no model in the loop: the words are the builder's own fallbacks. What is measured here is the machinery, not the prose.
- plan:
plans/building-permits.yaml(14 needs, 7 groups, 12 buckets) - intake:
intakes/building-permits.yaml - 41 questions, 14 aggregates, 198 access rules, 14 needs structurally met
What was proved
iris replay runs cases through portal's own answers::store_answer
and aggregates::transition, against a throwaway JetStream in docker -
the same code the running site uses, with its event log and its refusal
of undeclared moves. Fifteen cases, each one a path a real building case
walks (cases.jsonl).
| transitions accepted | refused by the engine | |
|---|---|---|
| as first built | 36 | 3, all of them legal paths |
hand-corrected aggregates.yaml |
45 | 2, both of them illegal paths |
| rebuilt from the plan, after the fix | 45 | 2, both of them illegal paths |
The three refusals in the first row were the sector, not the site:
REFUSED: asked-twice -> incomplete (complete -> incomplete)
REFUSED: hearing-sent-it-back -> incomplete (on_hearing -> incomplete)
REFUSED: we-changed-our-mind -> changed_by_us (reconsidered -> changed_by_us)
and meanwhile the engine happily accepted open -> refused (refusing an
application on the day it arrives, before anyone has read it) and
open -> overturned (the appeals board overturning a case that never
reached them).
Portal's aggregate engine needed no change at all. Hand-writing the
correct graph into aggregates.yaml - a cycle back to incomplete,
twice, and a success state reachable from the middle of the line - was
accepted as-is and enforced exactly. The whole gap was in the trainer's
plan language, which could only describe a line that goes forward.
What was fixed in the trainer
reached_from (src/build.rs). Where a stage is entered from, when
the line does not say it:
reached_from:
incomplete: [open, complete, on_hearing] # sent back for papers
refused: [complete, on_hearing, recommended] # never on arrival
A stage named there is entered from exactly those stages and nowhere
else, so saying where a refusal may come from also says where it may
not. It covers both of the sector's shapes - a case sent back to a stage
it was already past, and a good ending partway along - and it subsumes
turned_down_from, which was one list for every bad end at once and so
could not say that an applicant may withdraw from anywhere while the
authority may refuse only once the papers are in. The rebuilt site now
produces exactly the hand-corrected graph, and the access policy follows
it: section_head gets complete -> refused and no longer
open -> refused; appeals_board gets only before_the_board -> upheld
and -> overturned.
The public-view rule is now per bucket, not per need. A permit register holds the owner's small application and the approved firm's large one, and is one page either way; the old rule refused to publish a bucket the moment any role filed into it, which made "nobody outside this building can see a case" - the intake's own complaint - unfixable. A bucket only a role ever writes to is still refused, and that is why the hearing register here is internal.
What portal cannot do yet
Each of these is a change proposed, not made. They are ordered by how much this sector needed them.
1. A submitter may only act while the record is untouched
src/access.rs:223 pins a self_transition's from-state to open:
if let Some(st) = &alt.self_transition {
let from = crate::answers::OPEN_STATE;
The runtime is already general - self_transition_answer passes the
record's actual state as context.from and asks the policy - so this is
the compiler alone. The effect is that the applicant who is written to
for a missing drawing cannot send it: incomplete -> complete is in the
machine and in the plan, and the policy never emits it. Everything about
staying in dialogue with a person across a case dies here.
Proposed: self_transition takes an optional from: [state, ...],
defaulting to [open]; access.rs emits one rule per from-state, and
the lint checks each named state exists and that from -> to is
declared in the bucket's schema. The trainer already computes the set
(moves_of(need, SUBMITTER)) and would just write it down.
2. A record cannot belong to another record
An objection belongs to an application. An exemption rides on one. An
appeal is against a decision; an inspection is of a case; a hearing
answer answers a hearing request. Six of this site's twelve buckets hold
records that are about a record in another bucket, and the only thing
joining them is a case_number the person types in from a letter, which
nothing validates, nothing resolves, and nothing can list.
This is the largest structural gap, and it is why the case officer here still cannot be shown "this application and its four objections" on one page - the thing the intake asks for twice.
Proposed: a field type: record with of: <bucket>, storing a record
id, validated by the lint against the bucket and at submission against
the store; the parent's desk row then offers its children, and a public
case page can be assembled from the parent plus everything pointing at
it. This is a real piece of work and should be its own release, not a
rider.
3. Nothing moves a stage except a person
attended_by lets a plan document a stage no desk button moves - here,
the hearing's six weeks - but nothing actually moves it. The record sits
in open until someone notices. The intake names this as its number one
problem: "the twelve-week clock, and the fact that stopping it and
starting it again is done by hand in a spreadsheet."
Proposed: a state in aggregates.yaml may carry after: { days: 42, to: no_answer } beside its event: and mail:, and something with a
timer fires it. gdo already holds a durable consumer, already reads
content, and already sends what a state tells it to - this is the same
shape as mail:, one field along.
4. A public view publishes the whole bucket
watched_by: [public] opens every record in the bucket, including the
ones that were refused and withdrawn. For a permit register that is
roughly right and for a register of firms it is not, which is why
firm-approval here is not public though the list of approved firms
genuinely is.
Proposed: public: { states: [...] } on a bucket resource.
5. A need whose success is a membership cannot say so
firm-approval ending in approved is exactly what makes a firm a
member of approved_firms, and therefore what lets it file a
permit-application at all. Today somebody invites by hand afterwards.
Proposed: grants: <group> on a state, next to mail:, using the
invite machinery that already exists.
Also found
- The planner (
portal-trainer plan) fails withexited with exit status: 1wheneverANTHROPIC_API_KEYis set to anything the nested CLI rejects.--llm 'env -u ANTHROPIC_API_KEY claude -p'is the fix. This plan was written by hand, so the planner has not yet been asked to produce anything this size. - Two needs sharing a bucket emit the submitter's rule twice; harmless, but the access listing has duplicate rows.
Reproducing
docker run -d --rm --name iris-replay -p 127.0.0.1:14222:4222 nats:2.14.6-alpine -js
portal-trainer build --plan plans/building-permits.yaml --out runs/permits-2026-09-25 --lint iris
cd runs/permits-2026-09-25
iris check --path questions --access
iris replay --path questions --nats nats://127.0.0.1:14222 --cases cases.jsonl
fixed/ holds the hand-corrected aggregates.yaml from before the
trainer could produce it, kept as the proof that portal's engine was
never the thing in the way. Point replay at a throwaway NATS, never a
site's own: it writes.
Routing: one stable failure, and a leak that turned out not to cost anything
Twenty-five routing tasks, three seeds, qwen3:8b as the visitor, with the builder's own fallback wording and no model having touched the prose:
seed 1: 18/25 (72%) seed 2: 20/25 (80%) seed 3: 22/25 (88%)
A sixteen-point spread is wider than this harness's usual floor, so the misroutes were counted per task rather than per run. One fails in every seed:
shop-to-flats@/want-to-build(3 of 3), andgarage-ownerthe same way (2 of 3): someone who wants to sit down with us before paying an architect picks "permission to build it" instead. The pre-application meeting does not read as distinguishable from applying. A wording fault on the door page, and the kind a wording round exists to fix.
Everything else failed once - but the shape was not random. Of the eight front-door misroutes across all three seeds, six went to one card: a neighbour who got a notice, an owner asking for a completion certificate and someone reporting a building behind a fence all picked "the decision looked at again, by us and then by the board".
The front door held two doors and two bare needs - "Want to build", "Building near me", the appeal, and firm approval. A door is an abstract category; a bare need is a concrete sentence, and the concrete sentence was out-pulling the categories, collecting everyone with any sort of grievance whatever it was about.
So the obvious fix was measured: four doors, nothing bare, everything
on the front door at the same level (runs/permits-doors-2026-09-25).
| seed 1 | seed 2 | seed 3 | mean | |
|---|---|---|---|---|
| two doors, two bare needs | 72% | 80% | 88% | 80.0% |
| four doors | 76% | 72% | 84% | 77.3% |
The rewrite does not beat the spread, and is rejected. The
diagnosis was right and the fix was still not worth making: with four
doors the appeal card stopped collecting misroutes completely - 0 of 10,
down from 6 of 8 - and the site got no better, because two offers went
from findable to hidden. firm-insurance-lapsed ("our certificate has
expired, we have renewed it, here it is") found "to be on the list, and
then to file without asking again" when it was a sentence on the front
door, and fails 3 of 3 once it is behind a door called "Work as a
firm". owner-explains got worse the same way.
What that says, as a measurement rather than a taste: a concrete offer standing among categories does leak, and hiding it costs more than the leak. The earlier finding - four doors beat ten offers, six offers beat doors - was about how many things a page holds. This one is about what a door costs the person who was going to pick correctly: one more page between them and the only thing they came for. The mixed front door stays.