32. Taste is a separate, sourced score that never gates
- Status: Accepted 2026-10-25 on §7 row 5's taste acceptance (the separation law holds with 0 reversed and an empty ledger), with the caveats of plan-soundness D14 and D15 (
docs/plans/plan-soundness/README.md§3), built by PRs #244 (PS5.1a), #247 (PS5.1b), #248 (PS5.2, the residential-advisory taste profiles) and #246 (PS5.3, taste as the last tier ofvalidate --jsonnext); D11 records the layer ruling - Date: 2026-10 (proposed 2026-10-24, accepted 2026-10-25)
- Relates to: ADR 0005 (facts and advisory lint, honestly named profiles; this ADR adds a second advisory channel beside lint), ADR 0022 (exact decisions, counted budgets), ADR 0011 (no machine fix is offered for taste)
- Scope: a new library function
taste(), a new--taste <profile>mode ofarch score, a requiredlayerandcodeson every lint rule.compile(),describe()andlint()output is unchanged. - Numbering: 0030 and 0031 are reserved by the archlang-3d programme's drafts (
docs/plans/archlang-3d/).
Context
The plan-soundness programme (docs/plans/plan-soundness/README.md) splits what a plan can get wrong into four layers: validity (lint), advisory code (lint profiles), intent (a brief) and taste: the privacy gradient, day and night zoning, light, a living room at the heart, alignment and proportion. Decision D2 of that programme puts taste in a separate score, but the programme's README is deleted when the programme ends, and ADR 0005 allows "honestly named, caveated advisory checks" without saying anything of a score, a cap or a second channel. Putting taste in lint() would move the lint output of the whole corpus every time a threshold is tuned, and turn a cultural preference into a warning an agent is told to clear.
The design record is docs/plans/plan-soundness/research/2026-10-24-ps5-taste-design.md. It measured that the defect corpus's clean twins are not lint-clean (28 of 70 still warn), so the cap alone cannot separate good from bad plans, and that three starting criteria (R3, R7, R14) already separate the corpus's circulation and daylight pairs.
Decision
- Quality judgements that are profile- or culture-dependent go to
taste()andarch score --taste <profile>, never tolint()ordescribe(). Neither of those ever imports the taste module (test/taste-invisible.test.tswalks the import graph), and taste draws nothing. - Only plain defects become
W_*lint rules (D2). A taste criterion graduates to lint only with a defect-corpus case, a clean twin, and the corpuslint()movement explained. - The score is in [0, 1], computed in integers, never gating. Each criterion's measured value is a rational of integers; ramps, means and the cap are decided in
BigIntand rounded half up to an integer permyriad, printed byfmtPermyriad(src/num-format.ts); the JSON numbers are the parse of those strings. No float sits upstream of a pinned byte. The score never sets an exit code:arch score --tasteexits 0 on a measurement, 2 on compile errors, 3 on bad usage. - The validity cap reads layers, not a list. The total is capped at 0.5 while default-ruleset
lint()reports a warning from a rule whose layer isvalidity. Every lint rule declareslayer: "validity" | "drafting"and thecodesit may emit (LintRule, required fields, so the compiler refuses a rule without them);lintWithCoverage()returns the code → layer map beside the diagnostics, never inside them, andlint()'s output is unchanged. Drafting rules (the annotation family:W_DIM_INSIDE,W_DIM_OVERLAP,W_OPENING_NOT_DIMENSIONED, and the annotation codes later cards add) never cap; they are listed invalidity.drafting. Compile-channel warnings (W_ROOM_OVERLAP, thewhiledeprecations) never cap. Profile-only warnings (later cards) go toadvisoryand never cap. The taste module keeps no code list of its own (test/lint-layers.test.tsholds every code to exactly one rule). - Coverage first. A criterion that cannot judge says so (
assessable: falseand a reason) and is excluded from the total, never counted as a pass.scoreisnullwhen nothing is assessable.coverage.weightreports how much of the profile was judged, so a plan hollowed out by relabelling shows it. - Messages name rooms by label, as lint's do; ids appear only in
evidence[].ids. Advice codes areA_*, kept insrc/taste/advice.ts, not in the error catalogue and not inarch explain. - Bounded by counts. One
TASTE_BUDGETper call, charged before each criterion's work at its declared cost; a criterion that would exceed it reportstaste-budget. The budget was set by measurement: 6,500,000 units, 1,054 times the largest corpus charge (6,163,terrace-row, with the eleven criteria of PS5.1; it was 2,000,000 against 1,559 with PS5.1a's three). Each cost bounds the work its criterion counts, held by test on the corpus and a probe plan. - Every threshold cites a source; a value remembered rather than read ([M]) is verified before it ships. Profiles are advisory names (ADR 0005). PS5.1a ships
genericonly. - Calibration is deterministic over the repository's corpora: the separation law (
test/taste-separation.test.ts) reads the defect corpus andtest/taste-corpus/; no paid eval, no live model run, no claim of a model-loop gain.
Consequences
- An agent gets a second, slower-moving signal it may optimise without moving lint, and the corpus
lint()digests do not move when a taste threshold is tuned. - A taste criterion can be wrong without being harmful: it never blocks a build. Its errors show up as separation-law rows and pinned digest rows, each explained when it moves.
- Multi-dwelling plans (
terrace-row) are read as one dwelling until declared dwellings exist (PS2.14); upper storeys are skipped by the depth-reading criteria until their entrance depths are reliable (PS0.3). Both are visible incoverage.skipped. - A valid plan with poor taste can score below a capped invalid one: the cap bounds the invalid plan from above; it does not lift the valid one. PS5.1a's one ledger pair showed it (the fix that clears a lint warning narrowed a window). The ledger has been empty since PR #247, by dilution, not by a fix: R9 lifts that twin's total above 0.5 while R7 still prefers the bad case (0.7677 against 0.4606).
LintRulegains two required fields, so a rule cannot be added without a layer.- An unknown profile throws.
taste()raises aRangeErrorfor a profile name it does not know, wherelint()ignores an unknown profile and runs the defaults. A taste report names its profile and takes its thresholds from it, so a silent fallback would report a score under a profile nobody asked for. The profile is the caller's argument, not user source, so the never-throws contract on source stands;arch score --tastechecks the name first and exits 3 with a did-you-mean. - Messages fall back to the room id today, against decision 6. A room with no label is named by its id in
message(namesOf,src/taste/criteria.tsline 98, the samelabel ?? idfallback as lint'slabelOf). It is fixed with lint's under plan-soundness card PS2.29 (id-free messages). - Measured at acceptance (2026-10-25,
docs/plans/plan-soundness/audits/2026-10-25-first-contact-rerun-row5.md). On the corpus the score separates as the separation law requires: 41 pairs, 37 ordered, 0 tied, 0 reversed (the ledger is empty), 4 unjudged. Its reach on first-contact defects is small and orthogonal to the audits:score --taste genericnames 0 of the 57 defects of the 2026-10-18 plans, 3 of the 48 of the 2026-10-24 plans plus one partial, and 2 of the 49 of the 2026-10-25 plans, all of them kitchens hidden by auses livingtype (R6) or the night zone (R15); its most active criteria, R16 and R17, judge things no audit counted. Uptake was nil: 0 of 6 cold authors ranscore --tasteorvalidate, because the spec and the author role did not teach the step. PR #250 (PS7.4) put the loop into both. The 0 of 6 hits this ADR's reverse-if, but it is not yet assessed: the measuring dispatch prompt names onlycompile|lint, and the author role changed in PR #250. Plan-soundness D15 decides it with two arms on the same briefs, (A) the spec with the pre-#250 role text and (B) the current role, countingscore --tasteandvalidatecalls; taste stays an author tool only if arm A shows calls, and otherwise is re-scoped as a reviewer's tool.
Reverse if: agents measurably ignore a separate score (D2's own trigger).