The data contract — doctrine, naming grammar, worked field-shapes
references/contract.md — direkt aus der Skill-Doktrin gerendert.
Shape-truth moved to the schema (Plan 15). The machine-checkable JSON Schema at
contract/schema/*.schema.json, run bycontract/validate.mjs(shape + semantic tier + the MR-5 doc-lint), is now the single source of truth for every field’s shape. This document is the augmenting literature: the doctrine, the why, the naming grammar, and worked field-shape examples that sit on top of the schema — the same inversion astokens.ts → tokens.css. Where any shape statement here disagrees with the schema, the schema wins. Plan 15 also made the core target-only (zero back-compat): several shapes this file used to narrate were retired and are corrected throughout —
axisWeights/weightingPresets→weights/presets(drift ①)forkPositions/facetForks/liveFillerForks→ the generalizedoptions[].prune:{set,keep}typed-set narrowing (MR-1/MR-3;forkPositionswas a phantom — never built)mission.json+criteria.json→ folded intorequirements.json(drift ④); the standalone files are not read by the corekindenum → render-only string (no enforced enum; drift ③)- knowledge facet
build→effort(MR-2)- every field carries a
description, machine-enforced (MR-5)
This is the always-open doctrine lookup from Phase 2 onward. It explains how to think about the JSON files the kernel reads and the cockpit renders — paired with the schema, which defines their exact shape — plus the canonical naming grammar that settles every key-vs-id, German-vs-ascii, file-naming tension, and the thin scoring discipline that keeps the data honest. One contract spans the whole engagement — each file is one phase’s residue, and the schema is the common thread that keeps requirements, research, and presentation aligned.
This file owns architectures. It does not re-teach the model: the four axes, the
single-placement rule, the funnel, the fork’s two coordinates, coverage, and the
three registers all live in model.md — cited here, never restated. The cost
method (teardown → questionnaire → bands → 4-bucket rollup) lives in
pricing-tco.md; this file carries only the pricing field shapes.
The weight tiers
Every scored criterion carries a tier that sets how much it counts. The four
tiers (orthogonal to the axis it sits on — see model.md on Gate · Fit · Cost ·
Risk):
| Tier | Type | Weight | Purpose |
|---|---|---|---|
hard |
Binary (pass/fail) | n/a | Eliminate |
critical |
Scored 1–5 | ×5 | Differentiate decisively |
standard |
Scored 1–5 | ×3 | Differentiate meaningfully |
nice-to-have |
Scored 1–5 | ×1 | Tiebreakers |
A solution’s Fit score is the weighted sum over its scored criteria; the kernel
computes it. Hard filters are not scored — a single failure eliminates, full stop.
hard ≡ the Gate axis; critical/standard/nice-to-have are the scored
tiers used by both Fit and Risk (which answer different questions — see
model.md). There is no cost tier: cost is priced into the 4-bucket TCO, never
scored as a weighted criterion (see pricing-tco.md).
Schema-bound scoring discipline
These are the rules the data must obey — the doctrine behind them (single
placement, cost-out-of-Fit, the consultant-authored verdict) is in model.md.
- Filters first. A failed Gate is elimination — don’t score the rest. Record it
as a hard-filter
0, never a row deletion (see “Eliminations are recorded” below). - Eliminations are recorded, not deleted. Keep every eliminated candidate in the
data (hard-filter
0, or noted in the loop log) with the reason it fell. Criteria shift and understanding grows; a struck-through tool is the first place a later round looks, so never drop it from the matrix. - Rescore visibly. When the red team or a broadening round surfaces new evidence, update the score and note the change in the reasoning. Track it; don’t silently overwrite.
- Back-score new criteria across everyone. When a criterion emerges mid-engagement, score every existing candidate on it before trusting the ranking. A discovered criterion left as a footnote that never touches the totals is the classic way a “rescore” changes nothing it should have.
- Cross-check quant vs. qual. If the scores rank A first but the verdict prose
names B, something is wrong in one of them — investigate the underlying assumption,
don’t reconcile silently. The ranking is advisory; the human makes the final call
(the verdict is authored, not computed —
model.md). - Coverage ↔ score ↔ narrative must agree (the consistency invariant). For a use case, a
solution’s
coveragecode, its Fitscores[critId], and itsreasoning/qualitativeprose are three views of one truth and must not contradict. Anin(core delivers it natively) that scores Fit 2, abuildclaimed at Fit 5 with no work package, or prose praising a capability the score marks weak is a defect to investigate, not to silently reconcile. Coverage and Fit stay separate fields — neither is computed from the other (model.md§ Coverage) — so the rule is that they be consistent, and the mismatch stays visible until resolved.contract/validate.mjs’s semantic tier flags the mechanical cases (a scored cell with no reasoning, a coverage code with no matching criterion). - Compress dynamic range deliberately. If every candidate scores 4–5 on a criterion, sharpen the rubric or remove it — it isn’t differentiating.
- Cost stays a separate axis in the data. No criterion carries
axis:"cost"; the weighted score never contains money. Pricing fills thepricing/costModelfields and is presented as a band against the Fit score (pricing-tco.md).
Writing a hard filter — gate phrasing
A hard filter is a loose, literal minimum bar — not a quality judgment. Phrase it
so it can be judged true/false against its own text, escape hatches included (e.g.
“EU/EEA or SCC + TIA”). A candidate passes if it clears the written bar; how well it
clears it is not the gate’s business. Where degree matters, pair the gate with a
scored criterion that judges quality — e.g. the loose F-DSGVO-01 gate (“basic GDPR
compliance is reachable — DPA available, EU/EEA or SCC + TIA”) paired with the scored
N-GDPR-DEPTH-01 (“how thorough: default EU residency, sub-processor transparency, certs,
audit log”) — and keep the quality judgment out of the gate. When writing any hard filter,
ask: am I smuggling a “how good” judgment into a yes/no? If so, split it into a loose gate
plus a scored depth criterion. Over-tight or compound gates are how good tools get silently
and wrongly eliminated.
The same caution applies to fragile or optional capabilities — an AI layer, a third-party API, anything that’s the first thing to break is never itself a hard requirement. If it matters, gate the independence from it instead — require every use case to work without it (graceful degradation) — and let the enhancement’s quality score on Fit/Risk. Gating the fragile thing makes your whole shortlist hostage to it.
File schema
The cockpit (and your ingestion) expects these files in the engagement folder.
config.json — cockpit configuration
{
"title": "Acme Tool Evaluation",
"subtitle": "Choosing a booking + CRM stack",
"tierWeights": { "critical": 5, "standard": 3, "nice-to-have": 1 },
"categories": [
{ "key": "booking", "label": "Booking Software", "matrixFile": "matrix-booking.json" },
{ "key": "crm", "label": "CRM", "matrixFile": "matrix-crm.json" }
]
}
For a single-category engagement, use one category. tierWeights are adjustable
live in the UI, but these are the defaults.
Solution-layer additions — axis weighting. Beyond title / tierWeights /
categories, the solution layer adds Fit/Cost/Risk weighting via weights and
presets (drift ①: the canonical keys every instance ships; the former
axisWeights / weightingPresets are retired):
{
"weights": { "fit": 1, "cost": 1, "risk": 1 },
"presets": [
{ "key": "ausgewogen", "label": "Ausgewogen", "weights": { "fit": 1, "cost": 1, "risk": 1 } },
{ "key": "sicherheit", "label": "Sicherheit & geringes Risiko", "weights": { "fit": 1, "cost": 0.5, "risk": 2.5 } },
{ "key": "empfehlung", "label": "Meine Empfehlung", "recommended": true,
"weights": { "fit": 1.5, "cost": 1, "risk": 2 },
"reasons": "Kleine Organisation, Kinderdaten, harte Deadline — darum Risiko ×2." }
]
}
weights are the live Fit/Cost/Risk weights; presets are named
settings the analyst and client switch between (these are the Phase-5 value-fork
positions in data form). One preset may carry recommended:true and a reasons
string — that’s the consultant’s weighting, not “the answer”; the verdict is
authored separately (model.md § the verdict).
requirements.json — the consolidated authored input (Phase 1 + 2)
The single authored input a solution cockpit reads: it folds the former mission.json
(Phase 1) and criteria.json (Phase 2) into one file, alongside the authored narrative that
used to live only in requirements.md. requirements.md is generated from it (the criteria
tables are regenerated from criteria[], so they can never drift from the data the cockpit
scores on — cockpit.md build step; generator template/scripts/generate-requirements-md.mjs).
The core reads only ctx.data.requirements (drift ④, zero back-compat) — there is no
mission / criteria fallback. The standalone mission.json / criteria.json files are
retired in the core; the field-shape sections below document the shapes that now live
under requirements.json (purpose/useCases/… top-level, criteria[] under criteria).
{
"title": "Anforderungen & Constraints — <Kunde>",
"statusBanner": "One-line status (markdown) shown as a blockquote in the generated .md.",
"purpose": "…", "useCases": [ … ], "stakeholders": [ … ], "stressTest": "…",
"values": [ { "value": "A stated value, in the client's terms.", "evidence": "What it rests on." } ],
"narrative": {
"leitsatz": "The one sentence carrying the whole thrust (markdown).",
"sections": [ { "h": "Section heading", "md": "Authored markdown, emitted verbatim." } ]
},
"criteria": [ … ],
"costNote": "…", "notRequirements": [ "…" ],
"constraints": { "Category": "…" }, "openQuestions": [ "…" ]
}
purpose/useCases/stakeholders/stressTest— identical to themission.jsonfields below (now top-level here).useCases[]may carryid/currentState/improvementLevel/enablesexactly as inmission.json.criteria— shape-identical tocriteria.json’scriteria[](sameid/tier/axis/riskDim/useCase/areafields). The read path isctx.data.requirements.criteria. This is the single source the kernel scores on — never duplicate it into the generated.md.values(CR-A-4) — the client’s stated values as{ value, evidence }. Harvested from the engagement record; not fabricated as verbatim meeting quotes unless a transcript supplies them.narrative.leitsatz+narrative.sections[]— authored prose (register-2), emitted verbatim by the generator; this is where the bespoke layer model / sources / etc. live.costNote/notRequirements[]/constraints{}/openQuestions[]— the remaining generated-doc sections, each optional (the generator drops a heading whose field is absent).statusBanner/title— drive the generated.mdheader. Register-2 (analyst-facing).
The Mission fields — purpose / useCases / stakeholders / stressTest (Phase 1, under requirements.json)
Station 1 of the cockpit (the Mission page) reads these — now top-level fields of
requirements.json (the former standalone mission.json is retired in the core, drift ④).
They are the Phase-1 deliverable in machine form: the purpose, the tool-neutral use cases,
the stakeholders, and the one stress test. Call them use cases, never “jobs” — see
§ Naming grammar (D13). The station renders useCases[] directly.
{
"purpose": "One-sentence mission: what the system is for, stated tool-neutrally.",
"useCases": [
{ "id": "followup", "title": "Short label",
"what": "What the system must DO — never naming a product category.",
"currentState": "manual", "improvementLevel": "digitize", "enables": [] }
],
"stakeholders": [
{ "role": "Decides & pays", "who": "Who signs off the purchase." },
{ "role": "Uses daily", "who": "The lead users; their comfort is a requirement, not a nice-to-have." },
{ "role": "The sceptic", "who": "Who is most likely against it, and why." }
],
"stressTest": "The one concrete, near-future high-stakes scenario every option must pass."
}
useCases: tool-neutral — the test is can you state it without naming a product category? “Track follow-ups with reminders” is a use case; “needs a CRM module” is not. Use cases are the spine of the functional requirements (the spine semantics — what descends from a use case and what is free-standing — live inmodel.md).id: stable key. The Fit criteria that serve a use case reference it viauseCase— the spine link (seecriteria.jsonand § Naming grammar D7).currentState×improvementLevel: the transition the use case asks for (Ist → Soll) — what tells you how far it must move, and therefore how demanding its Fit criteria are.currentState:"none"(no process yet) ·"manual"(done by hand) ·"digitized"(already in some tool, with data).improvementLevel:"enable"(stand up a process that didn’t exist) ·"digitize"(lift a manual process into a tool) ·"improve"(sharpen an existing digital one) ·"ai-assist"(dock an AI layer on top).enables: ids of use cases this one is a precondition for. A supporting / enabling use case discovered mid-engagement — e.g. a knowledge base anai-assistprocess needs to work — is added here with anenablesedge to the use cases it unblocks, so a discovered enabler visibly feeds the others instead of floating free.stakeholders:{ role, who }— at least decides / uses-daily / sceptic.stressTest: the Phase-1 stress case (EFI’s was an 18-city tour) — one string.- Optional for a pure single-tool pick; required for a solution cockpit built from
template/.contract/validate.mjschecksrequirements.jsoncarriestitle/purpose/criteria(shape) and that the Mission fields are present (semantic warnings).
The criteria[] array — the requirements, as data (Phase 2, under requirements.json)
An array of criteria objects under requirements.json’s criteria key (the former
standalone criteria.json is retired in the core, drift ④). Produced in Phase 2 — it is
the requirements in machine form, and the human signs it off before research (the elicitation
instrument is requirements-interview.md).
[
{ "id": "SH-H1", "name": "GDPR compliance", "category": "shared", "tier": "hard", "weight": "0",
"description": "DPA available, EU hosting or adequacy, ISO 27001 / SOC 2. Binary." },
{ "id": "SH-C1", "name": "Approachability", "category": "shared", "tier": "critical", "weight": "5",
"description": "Non-technical owner productive in <1 week. 3 = needs training; 5 = self-evident." },
{ "id": "SH-P1", "name": "Vendor stability", "category": "shared", "tier": "standard", "weight": "3",
"description": "No acquisition / sunset risk; healthy funding and roadmap." },
{ "id": "BK-C1", "name": "Online self-booking","category": "booking","tier": "critical", "weight": "5",
"description": "Clients book 24/7 with no phone call. 5 = branded, books in <3 taps." },
{ "id": "SH-N1", "name": "API quality", "category": "shared", "tier": "nice-to-have","weight": "1",
"appliesTo": ["crm", "booking"], "description": "REST depth, webhooks, sane rate limits." }
]
category:"shared"(applies to every category) or a categorykeyfrom config.tier:"hard"|"critical"|"standard"|"nice-to-have"(see § The weight tiers).axis(optional):"gate"|"fit"|"risk"— which bucket the criterion measures (the axis doctrine and single-placement rule are inmodel.md). Omit it and the criterion falls back to the tool-level default (Gate ifhard, else Fit) — so existing tool-pick engagements need no change. There is no"cost"axis on a criterion (see § Scoring discipline). Enum is lowercase in data; Gate/Fit/Cost/Risk are the prose forms (§ Naming grammar D8).riskDim(only whenaxis:"risk"): the risk dimension this criterion feeds — e.g."datensicherheit"|"resilienz"|"abhaengigkeit"|"terminSicherheit". A solution’s score on a dimension is the average of its risk criteria with thatriskDim(some dimensions are instead hand-scored per solution — seesolutions.jsonriskScores). Field keys are lowercase-ascii; the German display label (Datensicherheit, Termin-Sicherheit) is a presentation concern (§ Naming grammar D8).description: a one-line definition, and for preferences what separates a low score from a high one. This is what the human confirms in the Phase-2 sign-off, and the cockpit shows it on hover — so a vague or compound criterion gets caught before research, not after. Strongly recommended on every criterion.weight: string;"0"for hard filters (they gate, not score). The UI’s tier weights override per-tier, so the per-criterion weight is mostly documentation.appliesTo(optional): restrict a shared criterion to specific categories.useCase(optional, Fit criteria): therequirements.jsonuseCases[]use-caseidthis criterion serves — the spine link. Absent = a free-standing criterion: a constraint or non-functional bar not descended from one use case (the spine semantics are inmodel.md).area(optional): a cluster label for grouping criteria in the composition views (e.g."Wissen","Offerte","Technik"). The views group by the distinctareavalues present — generalizes a hardcoded epic list, so the clustering is engagement-defined, never baked in.- ID convention:
SH-for shared, a 2-letter prefix per category (CR-,BK-,EV-…), thenH#/C#/P#/N#for hard/critical/standard/nice-to-have. IDs are keys — keep them stable once research references them (theidvskeyrule is § Naming grammar D7).
matrix-<category>.json — the filled form
An array of tool objects, one file per category. This is what deep research fills in.
[
{
"name": "ToolName",
"scores": { "SH-H1": "1", "SH-C1": "4", "BK-C1": "3.5" },
"reasoning": {
"SH-H1": "EU-hosted, ISO 27001, DPA available. 2–4 sentences citing evidence.",
"BK-C1": "Self-booking works but no group bookings; walked through the scenario..."
},
"qualitative": {
"verdict": "One-sentence final read on this tool.",
"strengths": ["Notable strength", "Another"],
"weaknesses": ["Weakness", "Another"],
"risks": "Key risks / red-team findings.",
"fit": "Why it does or doesn't fit THIS customer specifically."
},
"pricing": {
"model": "per-seat + per-resolution",
"drivers": ["seats", "resolutions"],
"oneTime": 0,
"estimate": { "optimistic": 3730, "expected": 6920, "pessimistic": 16300, "basis": "year1-seasonal" },
"verified": true,
"pricingUrl": "https://...",
"notes": "EEA add-on; annual-commit discount in optimistic; unverified AI rate high in pessimistic"
}
}
]
scores: keyed by criterion ID — no positional alignment. Hard filters are"0"/"1"; preferences"1"–"5"(floats like"3.5"allowed). Omit a key entirely for “not yet evaluated” (the cockpit shows it as?).reasoning: 2–4 sentence note per scored criterion, with specific data points. Shows as a tooltip on hover in the matrix.qualitative: every tool, including eliminated ones, gets a verdict + strengths/weaknesses/risks/fit. This powers the detail view.
The pricing field — cost teardown + 3-point TCO (per tool)
The cost teardown and 3-point band for a tool, produced by the pricing pass. The cost
method that fills these — the teardown, the parameter-union questionnaire, the
optimistic/expected/pessimistic computation, band-width-as-cost-risk — lives in
pricing-tco.md; the field shapes are here:
model— the charging model in words (e.g."per-seat + per-resolution", `"tiered flat- one-time"
,“per-seat OR self-host”`).
- one-time"
drivers— the 2–3 parameters that actually move the cost (["seats","resolutions"]).oneTime— one-time onboarding/integration cost (number;0if none).estimate— the 3-point band:optimistic/expected/pessimistic(numbers) plus abasisstring (e.g."year1-seasonal","steady-state-annual"). The cockpit renders this band and its width (= cost risk).verified—trueonce a human confirmed the rates against the vendor’s pricing page.pricingUrl,notes— source link and the key billing-mapping assumptions / excluded costs (ops labour, a mandatory second product, etc. — annotate these or the figure lies; seepricing-tco.md).- Back-compat: a simple
"annual": <number>(with optionaltier/perUser) still renders as a single list-price row when noestimateis present. Keep numbers as plain numbers/strings.
landscape.json — the raw candidate survey (Phase 4, optional)
Backs the cockpit’s Tools view (renderLandscape). The raw, pre-score output of the
landscape survey — a flat list of candidate tools with gate verdicts and capability
attributes, before any scoring. (The scored surface is solutions.json → Cockpit/Matrix,
or the bundled explorer.html for a pure tool pick — see cockpit.md.) Optional: omit the
file and the Tools view shows an empty note.
{
"tools": [
{
"name": "Acme Core", "vendor": "Acme GmbH", "category": "Core platform",
"url": "https://…", "summary": "One-line what-it-is.", "capabilities": "Free-text capability note.",
"gates": { "G-1": "pass" },
"tags": ["Cloud", "Open API"],
"attrs": { "Hosting": "EU cloud", "Pricing": "Sub €/mo" }
}
]
}
gates: an object keyed by the Gate-axis criterion IDs fromrequirements.jsoncriteria[](G-1above) →"pass" | "fail" | "unknown". The view renders one badge per Gate-axis criterion and reads its verdict here — so gates are engagement-defined, never hardcoded.category: a free grouping tag; the view’s category buttons auto-derive from the distinct values present.tags: free capability flags; the filter chips auto-derive from their union.attrs: freelabel → valuerows shown in the click-detail.summary/capabilitiesare the detail prose;urlis the outbound link.- This is engagement substance — re-derive it from this job’s survey; never carry another
engagement’s
landscape.jsonacross (transferring-between-engagements.md).
research-index.json — the research narrative + append-only log (Phase 4, optional)
Backs the cockpit’s Report view (renderReport). The readable synthesis of the survey:
one proposal sketch per architecture (a position), the cross-cutting assist bricks, the open
meeting questions, links to the raw research files — and the canonical append-only round log.
{
"meta": { "note": "One-line state of the research (e.g. Round 1 done, deep-dive open)." },
"rounds": [
{ "when": "2026-06", "focus": "Landscape survey across all three categories.",
"found": "5 live candidates; two anchors confirmed unviable.",
"shifted": "Killed the fragmented best-of-breed architecture; F-2 now leans 'fertige Basis'.",
"findings": [
{ "id": "H1", "revisedIn": "R2", "note": "Andock-Paradox: licensable ⇒ closed; open ⇒ pool-gated." }
] }
],
"positions": [
{ "pos": 1, "posLabel": "Finished platform", "name": "Acme Core", "role": "recommended",
"confidence": "How solid the finding is.", "proposition": "The core pitch in one line.",
"optimizes": "What it optimizes for.", "tradeoff": "What you give up.", "briefRef": "Source brief.",
"standouts": [{ "name": "Acme Core", "tag": "Favourite" }],
"openQuestions": ["What must still be clarified."], "expectedFailure": "Where it likely breaks." }
],
"assists": [
{ "label": "Cross-cutting brick", "proposition": "What it does across positions.",
"found": "What the research found.", "open": ["Open question."] }
],
"meetingQuestions": ["Open question for the meeting."],
"researchFiles": [{ "label": "Landscape findings", "path": "…", "what": "Readable synthesis." }]
}
rounds[](optional but canonical): the append-only research log — one entry per research round, in order.when·focus(what the round investigated) ·found(what it surfaced) ·shifted(how it moved the picture — eliminations, rescores, a fork that tilted). This is what makes the Report screen a log of how the engagement learned: a regression appends a round here while the live judgments (scores, shortlist, fork positions) are updated in place — so history lives in the Report and current truth lives in the stations (the append-not-rewind regression rule is owned byprocess.md). Never rewrite an old round; add a new one. (rounds[]vsloop-log.md— see § Naming grammar D12: distinct roles, keep both.)findings[](optional) — the round’s named structural findings, each a stable{ id, revisedIn, note }. Theid(e.g."H1") is what an architecture’sverdict.basis[]points at;revisedInis the round the finding last materially changed (it appears, an elimination/gate-flip lands, a score crosses a tier). This is the bottom of the judgement hierarchy (model.md): a finding’srevisedIn, read against a parent verdict’sverdict.asOf, is what the kernel’sstaleness()compares to decide whether a standing architecture/comparison verdict needs re-examination. BumprevisedInonly on a real change — cosmetic edits must not trip the badge.
positions[]: one per architecture/architecture;roleis"recommended"or"anchor". Astandouts[]chip in the view jumps to that tool in the Tools view.assists[],meetingQuestions[],researchFiles[]: all optional sections — omit and the view drops the heading.- Engagement substance, like
landscape.json— re-derived per job, never ported.
The solution-level layer (Scenario + solutions)
Everything above is the tool-level layer — criteria + a tool matrix — and it is what the
bundled explorer renders. A solution-design engagement adds a solution-level layer on top:
the shared Scenario and the candidate solutions composed from those tools (the model is in
model.md § Composition). These files are consumed by the solution cockpit (built by copying
template/ onto the pinned shell + kernel — see cockpit.md), not by the bundled explorer.
Tool scores still live in matrix-<category>.json; solutions reference tools by name and add
build + integration + cost + hand-scored risk.
In the cockpit these ship as cockpit.json (the shared Scenario + the shared workPackages
library, hoisted out because shared setup is identical across paths) and
solutions-<variant>.json (the scored compositions) — not a standalone scenario.json. The
field schemas below are unchanged; only the file packaging differs (§ File-name reconciliation).
scenario (shipped in cockpit.json) — the shared demand model
A flat object of demand knobs. The exact keys are engagement-specific; what matters is that
every solution’s cost and load-sensitive scores read from this one object (the Scenario as
forcing function — model.md).
{
"seats": 4,
"activeMonths": 6,
"emailsSeason": 8500,
"chatYr": 5000,
"deflectPct": 60,
"peakMult": 10,
"peakWeeks": 2,
"dayRate": 500,
"annual": false
}
solutions-<variant>.json — the candidate compositions
An array of solutions. Each references tools (by matrix-<category>.json name), adds its
build/integration work packages, a cost model, and hand-scored risk dims.
[
{
"key": "composed-chatwoot",
"name": "Chatwoot + Sidecar",
"kind": "composed", // render-only label (no enforced enum; drift ③)
"tools": ["Chatwoot"], // catalog tools in this composition (→ Fit, Cost ③④)
"coverage": { // per use-case: what the core does vs. how a gap is filled
"F-TRIAGE-01": "in", "F-DRAFTS-01": "aug:llm", "F-LOOKUP-01": "part:knowledge", "F-MIGRATE-01": "proj" },
"architecture": "miete", // architecture key (→ architectures[].key); the Phase-4 board groups by this
"recommended": true, // optional — the AUTHORED verdict (register-1/2): this is the favoured solution
"verdictNote": "Offen, andockbar, EU-self-host — der pragmatische Mittelpunkt.", // one-line why, shown as the lead
"knowledge": { // per-facet CONFIDENCE (not value); absent facet → "offen". MR-2: effort, not build.
"spezif": "bekannt", "kosten": "geschätzt", "effort": "offen", "risiko": "offen", "fit": "bekannt" },
"status": "live", // live | offen | neu | raus — derived when absent
"riskScores": { "abhaengigkeit": 3, "terminSicherheit": 3 }, // hand-scored risk dims (1–5)
"costInputs": { // generic cost inputs (setup/recurringYr/…); skischule's typed costModel is an EXTENSION
"setup": 0, "recurringYr": 0 },
"workPackages": [ // → Cost bucket ① effort; bands = Termin-risk
{ "name": "Sidecar build + mailbox glue", "covers": ["Triage","Drafts"],
"effort": { "opt": 4, "exp": 7, "pess": 12 } }
],
"maintHrsMo": 2, // → Cost bucket ② maintenance
"risk": { "longevity": "...", "peak": "...", "dataControl": "...", "lockIn": "...", "busFactor": "..." }
}
]
kind— a render-only string on the buy↔build spectrum (drift ③: no enforced enum). Each instance maps its own vocabulary via itsKIND_LABEL(buy|rent|build,saas|chatwoot|eigenbau,composed, …); the kernel branches on none of it. Drives nothing mechanically; it labels the buy↔build spectrum.tools— the catalog tools in the composition; theirmatrixscores feed the solution’s Fit, their cost lines feed license/usage. Abuild-kind solution may have an emptytoolsarray — which is exactly why build needs the solution layer to appear at all (model.md§ the failure it prevents).costInputs— the generic cost inputs the base schema carries (setup/recurringYr/ …), priced under the Scenario. An instance with a richer cost engine ships it as an extension: skischule’s typedcostModel.components(seat× seats × months,usage× ademandVar,onetime,flatRange,aiSeat) lives incontract/schema/ext/skischule/solutions.schema.json, not the base.workPackages+maintHrsMoadd the build/ops layer.workPackages[].effort— optimistic/expected/pessimistic person-days. Count each package once per solution; never sum across solutions (shared setup is identical across paths). Bands, not points — the spread is the Termin-Sicherheit signal (the method ispricing-tco.md).riskScores— hand-scored risk dimensions (1–5) that aren’t derivable from criteria (typicallyabhaengigkeit,terminSicherheit). The criteria-derived dims (datensicherheit,resilienz) come from the matrix viariskDim. Keys are lowercase-ascii (§ Naming grammar D8).coverage(optional) — the composition, keyed by Fit-criterion id (i.e. by use case): how this solution meets each use case. The vocabulary —"in"/"part"/"part:<fillerId>"/"aug:<fillerId>"/"build"/"proj"— and the coverage→Fit bridge (coverage is the qualitative shape, the score is the graded quality; render side by side, derive neither from the other) are defined inmodel.md§ Coverage. The<fillerId>keys intofillers.json.- (retired —
forkPositions) The solution no longer carries a fork-positions map. Narrowing now lives entirely indecisions.json: a fork’soptions[].prune:{set,keep}declares which typed set (architectures | solutions | fillers) the option keeps live, and the kernel intersectskeepacross chosen options (MR-1;liveArchitectures/livePrune,engine/README.md).forkPositionswas a phantom — never built (Phase 0 ②) — so it is gone from the core, not migrated. architecture(optional) — the architecture key (→architectures[].key) this solution fills. The Phase-4 long list is grouped per architecture off this field (board column = a architecture, cards = the solutions whosearchitecturematches) — no schema nesting, the grouping is derived. Absent → the solution floats ungrouped.knowledge(optional) — the per-facet knowledge-state:{ spezif, kosten, effort, risiko, fit }, each"offen" | "geschätzt" | "bekannt"(MR-2: the facet iseffort, never the oldbuild; the base schema’sknowledge$def is closed, so a legacybuildkey is a hard error). This is confidence, not value — distinct from the score (the score is the answer, this is how far it is trusted), the doctrine inmodel.md§ The knowledge grid. A missing map, or a missing facet, defaults tooffen— so a fresh candidate carries noknowledgeand reads as all-open (the designer’s “init empty”).N Lücken= facets notbekannt(derived, not stored). Drives the Arbeitsbrett dots, the Schärfe grid, andinfoGain(cockpit.md).status(optional) —"live" | "offen" | "neu" | "raus"for the board pill. Derived when absent: gate-fail →raus; all-offen→neu; elselive. Butstatus:"raus"is also legal with no gate-0: a solution ruled out by a made decision (an unchosen fork option —model.md§ Two elimination causes) israusthough it passes every gate. The tworauscauses are distinct and must stay so — a gate-rausnames the failed gate and is irreversible; a decision-rausnames the causing decision and is reversible (re-open that decision and the solution returns tolive). Never invent a gate-0to force a decision-elimination to read asraus(the never-fabricate-a-gate rule,model.md). Araussolution stays in the data either way (the hard-filter-0/ never-deleted rule); the board renders it eliminated with its cause named — gate or decision.- (retired —
facetForks/liveFillerForks) The “a knowledge-gap that hides a value-fork” link is no longer a solution-side map. It is subsumed by MR-1’s generalized forks (MR-3): a gap that hides a decision is authored as adecisions.jsonfork whoseoptions[].prunetargets the relevantsolutions/fillers.facetForksandliveFillerForksare gone from the core (the latter was read by zero code — dead R4 output, Phase 0). recommended(optional, bool) +verdictNote(optional, string) — the authored verdict: which solution the consultant favours, and the one-line why. Register-1/2 data, not client copy — the cockpit renders it as the lead and the kernel ranking below it as the turnable instrument; the two may legitimately diverge (model.md§ Three registers, § the verdict is authored;cockpit.mdStation 4). Never encode the favourite in a shell copy-map — changing the recommendation must never require a code edit.revisedIn(optional, string round marker, e.g."R2") — the round in which this solution last materially changed (a gate flip, an elimination, a score crossing a tier boundary). It is the solution layer of the judgement hierarchy: an architecture/comparisonverdict.basis[]that names this solution’skeygoes stale (kernelstaleness()) whenrevisedInis newer than the verdict’sasOf. Bump it only on a real change; missing → never trips a parent badge.
The 4-bucket TCO (computed, not stored)
A solution’s cost is computed under the Scenario into four buckets — ① project/build +
integration effort (Σ work-package days × dayRate) ② maintenance (maintHrsMo → €/yr) ③
license/rent (Σ over tools costModels) ④ usage. Year-1 = ①+②+③+④; ongoing = ②+③+④. Each is
evaluated at three points (opt/exp/pess); the band width is cost risk. The full method —
including how buy/compose/build all reduce to the same four buckets — is in pricing-tco.md.
Nothing here is stored: the kernel computes the buckets from the fields above.
fillers.json — the gap-filler library (Phase 4, optional)
When a solution’s coverage says a use case is aug:<id> or part:<id>, that <id> names a
filler — a reusable way to cover what the core doesn’t (a bought sidecar, an API, a separate
tool, or a custom-built brick). Fillers are factored out of the solutions into one library so the
same gap-filler can be referenced by every solution that needs it, and so the Composition view can
auto-collect “what extra pieces does this solution need” without hardcoding.
{
"fillers": {
"knowledge": {
"name": "Knowledge / RAG layer",
"fillsAssistFor": ["F-LOOKUP-01"],
"options": [
{ "label": "Buy: muffinGPT", "kind": "buy", "effort": { "opt": 1, "exp": 2, "pess": 4 }, "costRef": "filler-knowledge-buy" },
{ "label": "Build: EU-LLM + vector DB","kind": "build", "effort": { "opt": 15, "exp": 25, "pess": 35 } }
],
"eliminated": [ // optional — same { key, label, why } as a fork; ways ruled out, kept with the reason
{ "key": "buy-muffin", "label": "muffinGPT (buy)", "why": "Legal-RAG halluziniert → Haftung; nur intern, menschgeprüft" }
]
}
},
"glue": { "name": "Integration layer", "appendWhenAnyFiller": true }
}
fillers.<id>— the filler keyed by the<id>used incoverage(aug:knowledge→fillers.knowledge).name— the human label shown on the filler card.fillsAssistFor(optional) — Fit-criterion ids this filler provides as an optional assist (the ✨ “optional AI-assist” row), as opposed to being the primary cover for apart:/aug:gap. Solution-aware:fillsAssistForonly makes the assist available — the ✨ row renders for a given solution only when that solution’scoverageactually references this filler (viaaug:<id>/part:<id>). A solution that can’t carry the filler (e.g. a closed spine with no open API) shows no assist row for that criterion. (Cockpit half:cockpit.md§ Composition.)options[]— the buy-vs-build ways to provide it.kind:"buy"|"build".effortreuses the work-package band shape{ opt, exp, pess }— so a chosen filler’s effort folds into the solution’sworkPackages(Cost bucket ①) and the band feeds Termin-Sicherheit.costRef(optional) keys into the instance cost model for buckets ③/④.glue— the integration middleware that wires fillers to the core.appendWhenAnyFiller:truemeans the Composition view adds it automatically whenever a solution needs ≥1 filler — the fragmented-vs-single-core cost made visible.eliminated(optional) — filler ways ruled out, each{ key, label, why }(same architecture as a fork’seliminated), kept with the reason and rendered struck under the Baustein card — the below-solution home of the never-deleted rule.- Engagement substance — the vocabulary (
in/part/aug/build/proj) and the mechanism travel to a new engagement; the actual fillers and options are re-derived from this job’s survey, never ported (transferring-between-engagements.md).
The spine as data — the fork and the architecture record
The files above aren’t a flat schema — they are the phases as data (the funnel in
model.md). This section names the two objects the earlier schema left implicit — the fork
and the architecture — and is additive: every field below already appears in a live
engagement, and nothing here is required of a plain tool pick.
| Phase | Object | File(s) | Status |
|---|---|---|---|
| 1 Mission | the use cases (tool-neutral) | requirements.json useCases[] |
above |
| 2 Requirements | criterion | requirements.json criteria[] |
above |
| 3 Architecture | fork + architecture | decisions.json, architectures*.json (unfilled) |
below |
| 4 Solutions | solution (a filled architecture) | solutions*.json (filled) + matrix-*.json |
above |
| 5 Decisions | value-fork positions + presets | config.json presets |
above |
The fork object — decisions.json forks[]
A fork is a decision whose options map to architectures (the fork’s two coordinates and two
axes are in model.md § the fork). The schema (verbatim from a live engagement):
{
"id": "F-1",
"title": "Eigentum vs. Convenience",
"question": "Portablen Kern besitzen — oder schlüsselfertige Miete?",
"decides": "Eigene dünne Spine besitzen oder Plattform mieten.",
"modes": ["wert"], // resolution coordinate → 🎯wert | 📊rating | 🔍fakt
"scope": "architecture-hinge", // leverage: architecture-hinge | within-architecture | cross-cutting | tool-level
"dependsOn": { "fork": "F-2", "option": "ja" }, // optional inter-fork constraint
"options": [
{ "key": "eigen", "label": "Eigene dünne Spine besitzen",
"prune": { "set": "architectures", "keep": ["s4-eigene-spine-plus-satelliten", "s5-weitgehend-eigenbau"] } },
{ "key": "miete", "label": "Plattform mieten",
"prune": { "set": "architectures", "keep": ["s1-schlanke-plattform", "s2-plattform-plus-ki-wissensschicht", "s3-best-of-breed"] } }
],
"eliminated": [ // optional — options ruled out, kept with the reason (never deleted)
{ "key": "kaufen", "label": "muffinGPT (kaufen)", "why": "R2/H2: pool-/makler-geformt, kein Einzel-Vertreter-Fit" }
]
}
The two fork coordinates from model.md, mapped onto the fields:
- Resolution coordinate →
modes(array; an entry is"wert"/"rating"/"fakt", the 🎯/📊/🔍 router that names which step closes the fork — value at 5, rating at 4, fact at 1–2). A companionresolutionModesdict in the same file defines each mode’sicon/label/who/when. An optionalmodeDetailstring carries a mixed-mode note. - Leverage axis →
scope(architecture-hinge= high-leverage, narrows the architecture set directly → surface first;within-architecture/cross-cutting/tool-level= lower leverage). - The narrowing map →
options[].prune:{set,keep}(MR-1) —setnames the typed reference set (architectures | solutions | fillers) andkeepthe keys an option keeps live. The kernel intersectskeepacross chosen positions (livePrune/liveArchitectures/architectureFromForks,engine/README.md). An option with noprunedoesn’t touch the space. (The former architecture-onlyoptions[].architecturesshorthand is retired — zero back-compat in the core.) - Discovery coordinate → optional
discoveredAt(the phase a fork surfaced). Absent on existing forks, which is fine — discovery defaults to “Phase 3, when you first go looking.” - Plus
id·title·question·decides·status(free), anddependsOn{ fork, option }for a fork that’s only live under another’s position. eliminated(optional) — options ruled out during the loop, each{ key, label, why }with the literal gate/finding reason. This is the below-solution-level home for the “eliminations are recorded, never deleted” rule (process.md§ Eliminations): a fork keeps its discarded options visible (struck through in the cockpit) instead of silently dropping them. The same{ key, label, why }architecture lives on a filler (below).
reviewAgenda (in decisions.json) groups the forks by who closes them (clientDecides vs
weDecide), which is just the resolution coordinate read across all forks.
Architecture record vs. solution record — one file family, two maturities
The record holds one evolving form per architecture at two maturities — this is the
architecture-vs-solution split as data (the conceptual split is model.md § Architecture is not
Solution). The file name tracks the dominant layer: architectures live in architectures*.json; once Phase 4
fills and scores them the file is renamed to solutions*.json (the architectures*.json → solutions*.json
maturity/naming rule is in model.md):
- As an architecture (step 2): tool-agnostic
{ key, name, summary, optimizes, tradeoff, longName, anchor, status, verdict }(architectures.schema.json). Which fork narrows to which architecture lives on the fork side now —decisions.jsonoptions[].prune.keep— not as a map on the architecture record. - As a solution (step 3, after the loop fills it): the same record grows
scores{},costInputs(or the instance’scostModelextension),workPackages,maintHrsMo,riskScores,coverage,knowledge— the scored composition documented under “The solution-level layer” above. (NoforkPositions— retired.) - A architecture may also carry
recommended/verdictNote(the authored verdict at the architecture level — same meaning as on a solution) andstatus: "deferred"— a architecture that is named but not yet surveyed or filled. Adeferredarchitecture with no solutions stays a labelled empty column on the board (cockpit.md§ Arbeitsbrett), never silently dropped, so the architecture set reads consistently across the Reise and the board. - An architecture (and the overall-comparison object) may also carry a
verdictsub-object — provenance + a timestamp, never the value itself:{ "asOf": "<round>", "basis": ["H1", "<solKey>", "<gateRef>"] }.asOfis the round the standing judgement was last authored;basis[]lists the children it rests on — finding ids (fromrounds[].findings[]), solutionkeys, and/or tool gate refs. The value of the verdict staysrecommended/verdictNote/verdictTone(authored — P1); theverdict.*block only lets the kernel’s purestaleness()detect when abasis[]child has arevisedInnewer thanasOfand surface a “neu zu prüfen” badge. All optional → missing renders exactly as today (no badge). This is the architecture layer of the judgement hierarchy (model.md§ The judgement hierarchy); the operational stamp-and-flag protocol is inprocess.md. ⚠ Abasis[]ref that names no known finding id / solution key / gate is an orphan — it silently stops matching, so the verdict looks fresh forever.contract/validate.mjswarns on it (the semantic tier’s verdict-basis orphan check); keepbasis[]in sync when you rename a key.
So an unfilled architectures.json (architectures only) and a filled solutions-<variant>.json (scored) are the
same object before and after step 3 — name which fields are architecture (form) and which are
solution (the fill), never call an empty architecture a scored solution, and rename the file at the
Phase-4 roll-up.
The naming grammar — every key/id/label tension, settled once
This is the single home for the conventions that keep field names consistent across files, the kernel, and the cockpit. When two pieces of the contract seem to disagree on a name, the answer is here.
D6 — narrowing lives on the fork (options[].prune), not on the solution
Plan 15 MR-1/MR-3 collapsed what used to be a two-record relationship (forkPositions on a
solution ↔ forks{} on an architecture) into one home: the fork’s options. There is no
solution-side or architecture-side fork map any more.
decisions.jsonforks[].options[].prune:{set,keep}is the single source of narrowing.setis the typed reference set (architectures | solutions | fillers);keepis the keys the option keeps live. Choosing options setspositions = { forkId → optionKey }; the kernel intersects each chosen option’skeep(livePrune, specialized asliveArchitectures/architectureFromForks).- A solution carries no
forkPositions, and an architecture carries noforks{}— both were retired (forkPositionswas a phantom, never built). The live cockpit derives dimming and sibling grouping from the fork positions the user sets, not from a stored map on the records.
So when you want “this option narrows the space to these architectures,” author it as the fork
option’s prune, never as a map hung off the solution or the architecture.
D7 — id vs key: two key namespaces, documented (not mass-renamed)
The contract uses two key fields, by what references them:
id— on criteria (SH-C1) and use cases (followup). These are the things research references (a matrixscoresmap and acoveragemap are keyed by criterion id; a Fit criterion’suseCasepoints at a use-case id).idmarks “a stable handle other data points at — do not churn it once research starts.”key— on compositions/solutions (composed-chatwoot), categories (booking), fork options (eigen), and weighting presets (ausgewogen). These are selectors the config/cockpit switches between, not things research cites.
The rule is descriptive of the live data, not a refactor mandate: document the convention and
follow it for new fields; do not mass-rename existing live data to chase perfect consistency
(churning a referenced id is exactly the breakage the rule guards against). New criteria/use cases
get id; new solutions/categories/options/presets get key.
D8 — casing: lowercase-ascii data keys, German display labels, lowercase axis enums
Three casing rules that keep data machine-clean while letting prose read German:
- Field keys and dimension ids are lowercase-ascii —
datensicherheit,terminsicherheit,abhaengigkeit(ASCII fold ofä → ae). These are object keys the kernel reads; they never carry umlauts or capitals. - TitleCase German is a display concern only —
Datensicherheit,Termin-Sicherheit,Abhängigkeitappear as labels in the cockpit (and in this skill’s prose), produced by a label map, never used as a data key. - Axis enums are lowercase in data (
"gate"/"fit"/"risk"onaxis;fit/cost/riskinweights),Gate/Fit/Cost/RiskTitleCase in prose — the four axes are written capitalized in every reference, lowercase in every JSON value.
When you see terminSicherheit (camelCase) in older live data, that is a legacy spelling of the
terminsicherheit key — same dimension; per D7, don’t mass-rename it, just write new keys
lowercase-ascii.
D12 — rounds[] vs loop-log.md: distinct roles, keep both
Two records track the engagement’s history at different granularities — both are kept:
research-index.jsonrounds[]— the canonical, append-only DATA history. One structured entry per research round (when/focus/found/shifted), which drives the Report view. This is the machine-read log of how the picture moved; a regression appends a round here (above, and the append-not-rewind rule isprocess.md).loop-log.md— an optional per-iteration PROSE nav aid: a human-readable scratchpad of what each loop asked and found, finer-grained than a round, useful while you’re mid-loop. Not read by the cockpit.
They are not redundant: rounds[] is the structured history the client-facing Report renders;
loop-log.md is the working prose at a finer granularity. Keep both — don’t collapse the prose log
into the data rounds or vice-versa.
Three registers in the data
Every field above is one of two registers; the third never appears in these files (the doctrine is
model.md § Three registers):
- Model (the kernel reads): ids/keys,
tier,axis,riskDim,modes,scope,options[].prune,scores,costInputs/costModel,weights/presets. - Rationale (analyst prose, on the model, internal voice):
description,summary,question,decides,expectedFailure,tradeoff,gewinnt/gibtAuf,reasoning,qualitative. - Client copy is not in these files — it’s the shell’s register-3 override layer (keyed by
id, falling back to register 2 when unmapped; see
cockpit.md). The data stays blunt and internal; the shell polishes.
File-name reconciliation (same schema, two packagings)
Two harmless variations exist across engagements — same field schemas, different packaging:
config.jsoncategories[].matrixFile(a tool-level matrix) vs.categories[].solutionsFile(a solution-level set). Either points the cockpit at that category’s records; pick by which layer the category is at.- The shared Scenario ships either in
cockpit.json(alongside theworkPackageslibrary) or as a standalonescenario.json. Identical object (the demand knobs above); the cockpit reads whichever is present.
Validating the data
A truncated or inconsistent file breaks the cockpit silently, so don’t eyeball it — run the system
validator (the graduated successor to the old scripts/validate.py) and fix any errors before you
report back or move on. It runs three tiers: the MR-5 schema-lint, the JSON-Schema shape pass
(Ajv 2020), and the semantic cross-reference/consistency tier (contract/validate.mjs,
contract/semantic.mjs, contract/schema-lint.mjs).
node contract/validate.mjs <engagement>/data [--instance <name>] # full validation
node contract/validate.mjs --lint-only # MR-5 doc-lint of the schemas
An instance with a per-project extension (skischule’s cost engine, ehimare’s integration facet)
ships it under contract/schema/ext/<instance>/; the validator composes base + extension
automatically when --instance matches (or when the data lives at <instance>/data/).
It verifies every JSON file parses, criteria have valid tiers, scores reference real criterion IDs,
hard filters are 0/1, preferences are 1–5, and flags scored cells with no reasoning note.
Non-zero exit = fix it first. (The full ingest/audit write-procedure for a returned research
report — discard the report ranking, re-check the literal filters, then write and validate — is owned
by research-briefs.md; this file owns only the schema the validator enforces.)