docs(m2): decide O4 - the launcher refuses to serve without the ingestion guard

The order that carried plan steps 17-20 required this decision to be taken in
M2 rather than allowed to slide. Taken: the gate moves onto the guard, the
version floor stays as the second gate, selvsjekk keeps reporting.

Deciding argument: every skill degrades to manual paste without jobbsok-tools,
so failing closed costs a connector that does not appear, while failing open
costs untrusted text persisted through the one boundary this plugin exists to
hold.

Scoped deliberately: M2's first write (--oppdater) persists four derived
values and no untrusted byte, so it is not the trigger. Step 21 and Step 22
are, and the implementation is pinned there because it lives in files outside
this order's fence.

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 21:41:41 +02:00
commit 651d996004

View file

@ -422,6 +422,45 @@ Two things follow, and they should not be merged into one:
should refuse outright once a writing tool exists, is an M2 decision and is
not taken here.
### O4 decided (M2, 2026-09-05): the launcher refuses without the guard
**Decision. The gate moves onto the guard.** From the step that introduces the
first write of untrusted-derived content, `jobbsok_tools_launch.py` refuses to
serve when `llm_ingestion_guard` cannot be imported by the interpreter it
selected, and says so in words naming `python scripts/bootstrap.py`. The
version floor stays; it is now the second of two gates, not the only one.
`selvsjekk` keeps reporting `guard_versjon`, because a gate and a report answer
different questions -- the gate decides whether to serve, the report tells the
operator which build answered.
**Why not "report it and let it run".** The measured incident is exactly the
case where nobody asked. The server was serving, `guard_versjon` was `null`,
and it stayed `null` until a probe went looking. A signal that only fires when
someone requests it is not a gate; it is documentation with a return value.
**Why refusing is cheap here, and this is the deciding argument.** Every skill
in this plugin degrades to manual paste with `jobbsok-tools` absent -- that is
a build-brief invariant, not a hope. So a launcher that refuses does not strand
the operator: it removes the connector and leaves the skills working the way
they already work on a machine that never installed the server. The cost of
failing closed is a connector that does not appear. The cost of failing open is
untrusted text persisted through the one boundary this plugin exists to hold.
**What does NOT trigger it, stated so the rule is not widened by accident.**
M2's first write is `sak_status.py --oppdater`, and it writes four derived
values -- a state name and a waiting party from a closed enum, and two dates
computed from timestamps. No untrusted byte reaches that file through that
path, so the write-back is not what makes `guard_versjon: null` a defect. The
defect starts at plan Step 21 and Step 22, where `sak.md` and
`beslutninger.jsonl` first persist employer names, titles, URLs and notes that
came from a listing.
**Timing.** Taken here, in M2, as the order required. Implemented in the step
that introduces the first such write (Step 21), because the change lives in
`scripts/jobbsok_tools_launch.py` and `scripts/jobbsok_tools.py`, both outside
the fence of the order that took the decision. A decision recorded without its
implementation date is a decision that evaporates; this one has a step number.
## Degradation branch
Written now, so the answer does not get decided under pressure later.