tools: generate the artifact page from canon markdown

The page and the ADR had already diverged once. They are now one source: the
markdown is authoritative and the page is generated, never hand-edited.

The renderer recognises conventions the document already uses rather than
requiring extra markup, so the source stays a readable document. A table whose
first column is **X0**/**X1** becomes a level ladder; a table headed Threat
becomes the threat matrix; a table with a Kind column gets mechanical and
adversarial chips; a table headed "E \ P" becomes the two-axis grid; ## N.
headings build the section rail.

Stdlib only, per the structure-not-tooling stance. A publishing step that needs
its own toolchain is a publishing step that stops being run - the same
reasoning tenant-engine used for its pin check.

One real consequence beyond deduplication: the E x P matrix had existed only on
the page, so the canonical document did not contain its own central diagram.
It is now a table in the markdown, which is where it should always have been.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
codex 2026-08-17 15:10:22 +02:00
parent c4f22a5e55
commit 1c65257352
4 changed files with 587 additions and 4 deletions

View file

@ -193,3 +193,10 @@ agents-update:
agents-validate: agents-validate:
@echo "Validating agents..." @echo "Validating agents..."
@kaizen-agentic validate agents/ @kaizen-agentic validate agents/
.PHONY: artifact-adr008
artifact-adr008:
python3 tools/render-artifact.py canon/architecture/adr-008-multi-tenancy-model.md \
--output build/adr-008.html \
--title "Tenancy Posture" \
--subtitle "A framework for describing, holding and improving multi-tenancy — including where we are not there yet."

View file

@ -227,10 +227,24 @@ breach SHOULD target E3 or above.
| **P3** | Dedicated cluster per tenant. | Business apps per `business-app-service-contract` §1.2 | | **P3** | Dedicated cluster per tenant. | Business apps per `business-app-service-contract` §1.2 |
| **P4** | P3 plus separate region or jurisdiction. | None | | **P4** | P3 plus separate region or jurisdiction. | None |
**P0 → P1 → P2 does not raise the E level.** Those steps buy consumer Enforcement and placement are independent axes. Plotted together, with where
isolation, capacity predictability, independent retention and a smaller each service actually sits — parenthesised entries are targets or defaults
operational blast radius. Only P3 makes E4 reachable. This is the most rather than current positions, and `—` marks a cell the coupling in §3.2 makes
misusable fact in the framework and §11 governs how it may be described. unreachable:
| E \ P | P0 | P1 | P2 | P3 | P4 |
|---|---|---|---|---|---|
| **E4** | — | — | — | (business app) | |
| **E3** | | (target) | | | |
| **E2** | | tenant-engine<br>audit-core | | | |
| **E1** | (absorbed repo) | | | | |
| **E0** | | | | | |
**P0 → P1 → P2 is movement along the horizontal axis only.** Those steps buy
consumer isolation, capacity predictability, independent retention and a
smaller operational blast radius. They do not raise the tenant boundary by one
step. Only P3 makes E4 reachable. This is the most misusable fact in the
framework and §11 governs how it may be described.
**Decision 4.4.1:** P1 is the default for platform services; P3 for **Decision 4.4.1:** P1 is the default for platform services; P3 for
client-facing business apps, as already ratified. A service unsure which it is client-facing business apps, as already ratified. A service unsure which it is

185
tools/artifact-style.css Normal file
View file

@ -0,0 +1,185 @@
:root{
--paper:#EDEEF0; --surface:#F6F7F8; --surface-2:#E4E6E9;
--ink:#171D24; --ink-2:#4A5561; --ink-3:#737E8A;
--rule:#D3D7DC; --rule-strong:#B6BCC3;
--brass:#8A6A2E; --brass-soft:#EFE5CD; --brass-line:#C9AE74;
--clay:#8A3A2C; --clay-soft:#F2DFDA;
--l0:#DCE0E2; --l1:#B9C4C7; --l2:#8CA1A6; --l3:#567D84; --l4:#23555E;
--chip-fg:#F6F7F8;
--font-display:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
--font-body:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
--font-mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;
--measure:66ch;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--paper:#12161A; --surface:#191E24; --surface-2:#222831;
--ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E;
--rule:#2A3138; --rule-strong:#3B444D;
--brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426;
--clay:#D08A76; --clay-soft:#3A211B;
--l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD;
--chip-fg:#12161A;
}
}
:root[data-theme="dark"]{
--paper:#12161A; --surface:#191E24; --surface-2:#222831;
--ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E;
--rule:#2A3138; --rule-strong:#3B444D;
--brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426;
--clay:#D08A76; --clay-soft:#3A211B;
--l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD;
--chip-fg:#12161A;
}
*{box-sizing:border-box}
body{
margin:0; background:var(--paper); color:var(--ink);
font-family:var(--font-body); font-size:17px; line-height:1.62;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1180px;margin:0 auto;padding:0 24px 96px}
.layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:56px;align-items:start}
@media (max-width:960px){.layout{grid-template-columns:1fr;gap:0}.rail{display:none}}
/* ---------- rail ---------- */
.rail{position:sticky;top:28px;padding-top:8px;font-family:var(--font-display);font-size:12px;line-height:1.5}
.rail ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px}
.rail a{color:var(--ink-3);text-decoration:none;display:flex;gap:9px}
.rail a:hover,.rail a:focus-visible{color:var(--brass)}
.rail .n{font-family:var(--font-mono);font-size:10px;color:var(--rule-strong);min-width:16px;padding-top:1px}
.rail .grp{margin-top:14px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--rule-strong)}
/* ---------- header ---------- */
header{padding:64px 0 40px;border-bottom:2px solid var(--ink);margin-bottom:44px}
.eyebrow{font-family:var(--font-mono);font-size:11.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:14px;margin-bottom:22px}
.eyebrow .stat{color:var(--clay)}
h1{font-family:var(--font-display);font-weight:800;letter-spacing:-.035em;line-height:.94;font-size:clamp(46px,9vw,92px);margin:0 0 6px;text-wrap:balance}
.sub{font-family:var(--font-display);font-weight:500;font-size:clamp(16px,2.4vw,21px);letter-spacing:-.01em;color:var(--ink-2);margin:0 0 30px;max-width:34ch;line-height:1.3}
.metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:20px 28px;border-top:1px solid var(--rule);padding-top:20px}
.metagrid dt{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px}
.metagrid dd{margin:0;font-family:var(--font-display);font-size:13.5px;line-height:1.45;color:var(--ink)}
/* ---------- typography ---------- */
section{margin-bottom:60px;scroll-margin-top:24px}
h2{font-family:var(--font-display);font-weight:750;letter-spacing:-.022em;font-size:clamp(24px,3.4vw,31px);line-height:1.12;margin:0 0 18px;text-wrap:balance;display:flex;gap:14px;align-items:baseline}
h2 .sn{font-family:var(--font-mono);font-size:12px;font-weight:400;color:var(--brass);letter-spacing:.06em;flex:none;padding-top:2px}
h3{font-family:var(--font-display);font-weight:700;font-size:16px;letter-spacing:-.008em;margin:34px 0 10px;color:var(--ink)}
p{margin:0 0 15px;max-width:var(--measure)}
ul,ol{max-width:var(--measure);margin:0 0 15px;padding-left:20px}
li{margin-bottom:7px}
strong{font-weight:600}
em{font-style:italic}
code{font-family:var(--font-mono);font-size:.855em;background:var(--surface-2);padding:1px 5px;border-radius:2px}
a{color:var(--brass)}
.lede{font-size:19px;line-height:1.55;color:var(--ink-2);max-width:60ch}
/* ---------- devices ---------- */
.callout{border-left:3px solid var(--brass);background:var(--brass-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)}
.callout p:last-child{margin-bottom:0}
.callout .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px}
.rule-quote{border-top:2px solid var(--ink);border-bottom:2px solid var(--ink);padding:26px 0;margin:28px 0;max-width:var(--measure)}
.rule-quote p{font-family:var(--font-display);font-weight:600;font-size:19px;line-height:1.38;letter-spacing:-.014em;margin:0;text-wrap:balance}
.hard{border-left:3px solid var(--clay);background:var(--clay-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)}
.hard .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--clay);display:block;margin-bottom:8px}
.hard p:last-child{margin-bottom:0}
.dec{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.08em;color:var(--brass);text-transform:uppercase}
.vec{font-family:var(--font-mono);font-size:.9em;font-weight:600;background:var(--surface-2);padding:2px 7px;border-radius:2px;white-space:nowrap;letter-spacing:.04em}
/* ---------- tables ---------- */
.scroll{overflow-x:auto;margin:0 0 24px;-webkit-overflow-scrolling:touch}
table{border-collapse:collapse;width:100%;min-width:520px;font-family:var(--font-display);font-size:13.5px;line-height:1.45}
th{text-align:left;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);font-weight:400;padding:0 16px 8px 0;border-bottom:1px solid var(--rule-strong);vertical-align:bottom}
td{padding:11px 16px 11px 0;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink-2)}
td:first-child{color:var(--ink);font-weight:600}
tbody tr:last-child td{border-bottom:none}
.lvl{font-family:var(--font-mono);font-weight:600;font-size:12px;letter-spacing:.04em;color:var(--ink)}
/* ---------- ladders ---------- */
.breakout{margin:34px 0 40px}
.bhead{display:flex;justify-content:space-between;align-items:baseline;gap:20px;border-bottom:1px solid var(--rule-strong);padding-bottom:9px;margin-bottom:22px;flex-wrap:wrap}
.bhead h3{margin:0;font-size:13px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--font-mono);font-weight:400;color:var(--ink-3)}
.bhead .note{font-family:var(--font-display);font-size:12.5px;color:var(--ink-3)}
.ladders{display:grid;gap:26px}
.ladder{display:grid;grid-template-columns:126px minmax(0,1fr);gap:18px;align-items:start}
@media (max-width:700px){.ladder{grid-template-columns:1fr;gap:10px}}
.ladder .pname{font-family:var(--font-display);font-weight:700;font-size:14px;letter-spacing:-.01em;padding-top:2px}
.ladder .pname span{display:block;font-family:var(--font-mono);font-size:10px;font-weight:400;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-top:3px}
.rungs{display:grid;gap:3px;grid-template-columns:repeat(5,minmax(0,1fr))}
@media (max-width:700px){.rungs{grid-template-columns:repeat(2,minmax(0,1fr))}}
.rung{padding:9px 10px 11px;background:var(--surface);border-top:4px solid var(--l0);min-width:0}
.rung.r1{border-top-color:var(--l1)} .rung.r2{border-top-color:var(--l2)}
.rung.r3{border-top-color:var(--l3)} .rung.r4{border-top-color:var(--l4)}
.rung .code{font-family:var(--font-mono);font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ink);display:block;margin-bottom:4px}
.rung .txt{font-family:var(--font-display);font-size:11.5px;line-height:1.34;color:var(--ink-2);display:block}
.rung.na{opacity:.42}
/* ---------- matrix ---------- */
.matrix-shell{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:stretch;margin-bottom:14px}
.ylab{writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);text-align:center;padding-bottom:22px}
.mgrid{display:grid;grid-template-columns:34px repeat(5,minmax(0,1fr));gap:3px}
.mcell{background:var(--surface);min-height:60px;padding:6px;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;min-width:0}
.mcell.tint1{background:color-mix(in srgb,var(--l1) 26%,var(--surface))}
.mcell.tint2{background:color-mix(in srgb,var(--l2) 26%,var(--surface))}
.mcell.tint3{background:color-mix(in srgb,var(--l3) 24%,var(--surface))}
.mcell.tint4{background:color-mix(in srgb,var(--l4) 22%,var(--surface))}
.mcell.void{background:repeating-linear-gradient(135deg,transparent,transparent 5px,var(--rule) 5px,var(--rule) 6px);opacity:.55}
.rlab,.clab{font-family:var(--font-mono);font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--ink-3);display:flex;align-items:center;justify-content:center}
.rlab{min-height:60px}
.clab{padding-top:7px;min-height:22px}
.pin{font-family:var(--font-mono);font-size:9.5px;font-weight:600;letter-spacing:.02em;background:var(--ink);color:var(--paper);padding:2px 5px;border-radius:2px;line-height:1.3;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pin.ghost{background:transparent;color:var(--ink-2);border:1px dashed var(--rule-strong)}
.mnote{display:flex;gap:22px;flex-wrap:wrap;font-family:var(--font-display);font-size:12px;color:var(--ink-3);padding-top:6px}
.mnote .k{display:flex;align-items:center;gap:7px}
.sw{width:13px;height:13px;flex:none;background:var(--ink)}
.sw.g{background:transparent;border:1px dashed var(--rule-strong)}
.sw.v{background:repeating-linear-gradient(135deg,transparent,transparent 4px,var(--rule) 4px,var(--rule) 5px);border:1px solid var(--rule)}
@media (max-width:640px){
.mgrid{grid-template-columns:28px repeat(5,minmax(0,1fr))}
.mcell{min-height:52px;padding:4px}
.pin{font-size:8px;padding:1px 3px}
.rlab{min-height:52px}
}
/* ---------- methodology ---------- */
.verbs{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule)}
.verb{background:var(--surface);padding:18px 18px 20px}
.verb h4{font-family:var(--font-display);font-weight:750;font-size:15px;margin:0 0 7px;letter-spacing:-.01em}
.verb p{font-family:var(--font-display);font-size:12.5px;line-height:1.46;color:var(--ink-2);margin:0;max-width:none}
.verb .step{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;color:var(--brass);display:block;margin-bottom:9px}
/* ---------- questions ---------- */
.qs{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--rule-strong)}
.q{display:grid;grid-template-columns:34px minmax(0,1fr) 170px;gap:18px;padding:16px 0;border-bottom:1px solid var(--rule);align-items:start}
@media (max-width:760px){.q{grid-template-columns:28px minmax(0,1fr);gap:12px}.q .owner{grid-column:2}}
.q .qn{font-family:var(--font-mono);font-size:11px;color:var(--brass);padding-top:3px}
.q .qt{font-family:var(--font-display);font-size:14px;line-height:1.48;color:var(--ink-2)}
.q .qt b{color:var(--ink);font-weight:700;display:block;margin-bottom:2px;font-size:14.5px}
.owner{font-family:var(--font-mono);font-size:10px;letter-spacing:.05em;color:var(--ink-3);padding-top:4px}
.owner .tag{display:inline-block;border:1px solid var(--rule-strong);padding:2px 7px;border-radius:2px}
.owner .tag.need{border-color:var(--clay);color:var(--clay)}
/* ---------- misc ---------- */
.numbers{font-family:var(--font-mono);font-size:12.5px;line-height:1.85;background:var(--surface);border-left:3px solid var(--l3);padding:16px 20px;margin:0 0 22px;overflow-x:auto;max-width:var(--measure)}
.numbers .v{color:var(--ink);font-weight:600}
.numbers .k{color:var(--ink-3)}
pre{font-family:var(--font-mono);font-size:12.5px;line-height:1.68;background:var(--surface);border-left:3px solid var(--rule-strong);padding:16px 20px;overflow-x:auto;margin:0 0 22px;max-width:var(--measure);color:var(--ink-2)}
.alt{border-bottom:1px solid var(--rule);padding:14px 0;max-width:var(--measure)}
.alt:last-of-type{border-bottom:none}
.alt b{font-family:var(--font-display);font-size:14px;display:block;margin-bottom:3px}
.alt p{font-size:14.5px;margin:0;color:var(--ink-2)}
.alt .verdict{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--clay)}
footer{border-top:2px solid var(--ink);margin-top:20px;padding-top:22px;font-family:var(--font-mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
.tm td,.tm th{text-align:center}
.tm td:first-child,.tm th:first-child{text-align:left}
.yes{color:var(--l4);font-weight:700}
.no{color:var(--clay);font-weight:700}
.kind{font-family:var(--font-mono);font-size:9px;letter-spacing:.09em;text-transform:uppercase;padding:2px 6px;border-radius:2px;white-space:nowrap;border:1px solid var(--rule-strong);color:var(--ink-3)}
.kind.adv{border-color:var(--clay);color:var(--clay)}
.routes{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule);margin:0 0 22px}
.route{background:var(--surface);padding:16px 18px}
.route h4{font-family:var(--font-display);font-weight:750;font-size:14px;margin:0 0 6px}
.route p{font-family:var(--font-display);font-size:12.5px;line-height:1.45;color:var(--ink-2);margin:0;max-width:none}
.route .tag{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px}
a:focus-visible,.rail a:focus-visible{outline:2px solid var(--brass);outline-offset:3px}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}

377
tools/render-artifact.py Normal file
View file

@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Render a canon markdown document into a styled, self-contained artifact page.
Single source of truth: the markdown. The page is generated, never hand-edited,
so the two cannot diverge.
Design devices are recognised from conventions already present in the markdown
rather than from extra markup, so the source stays a readable document:
* a table whose first column is `**X0**`/`**X1**`... renders as a level ladder
* a table whose first header cell is `Threat` renders as a threat matrix
* a table with a `Kind` column renders with mechanical/adversarial chips
* a table whose first header cell is `E \\ P` renders as the E x P matrix
* a blockquote renders as a pull quote
* `**Decision N.N...**` at the start of a paragraph renders as a decision
* `## N. Title` headings build the section rail
Stdlib only, per the estate's structure-not-tooling stance: a publishing step
that needs its own toolchain is a publishing step that stops being run.
Usage:
python3 tools/render-artifact.py <source.md> --output <page.html> \\
[--title "Name"] [--subtitle "..."]
"""
from __future__ import annotations
import argparse
import html
import pathlib
import re
import sys
STYLE = pathlib.Path(__file__).parent / "artifact-style.css"
INLINE_CODE = re.compile(r"`([^`]+)`")
BOLD = re.compile(r"\*\*([^*]+)\*\*")
EM = re.compile(r"(?<![*\w])\*([^*]+)\*(?!\*)")
LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
HEADING = re.compile(r"^(#{1,4})\s+(.*)$")
SECTION_NO = re.compile(r"^(\d+)\.\s+(.*)$")
LEVEL_CELL = re.compile(r"^\*\*([IAEPR])(\d)\*\*$")
DECISION = re.compile(r"^\*\*(Decision [\d.]+[^*]*)\*\*(.*)$", re.S)
def inline(text: str) -> str:
"""Escape, then apply inline markdown. Order matters: code first."""
slots: list[str] = []
def stash(rendered: str) -> str:
slots.append(rendered)
return f"\x00{len(slots) - 1}\x00"
text = INLINE_CODE.sub(lambda m: stash(f"<code>{html.escape(m.group(1))}</code>"), text)
text = html.escape(text, quote=False)
text = LINK.sub(
lambda m: f'<a href="{html.escape(m.group(2), quote=True)}">{m.group(1)}</a>', text
)
text = BOLD.sub(r"<strong>\1</strong>", text)
text = EM.sub(r"<em>\1</em>", text)
for i, rendered in enumerate(slots):
text = text.replace(f"\x00{i}\x00", rendered)
return text
def split_frontmatter(source: str) -> tuple[dict, str]:
if not source.startswith("---\n"):
return {}, source
end = source.index("\n---\n", 4)
meta = {}
for line in source[4:end].splitlines():
if ":" in line and not line.startswith((" ", "-")):
key, _, value = line.partition(":")
meta[key.strip()] = value.strip().strip('"')
return meta, source[end + 5 :]
def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
rows, i = [], start
while i < len(lines) and lines[i].lstrip().startswith("|"):
cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
if not all(set(c) <= set("-: ") for c in cells):
rows.append(cells)
i += 1
return rows, i
# --- table renderers -------------------------------------------------------
def render_ladder(rows: list[list[str]]) -> str:
"""A level table becomes a stepped scale. Colour depth encodes strength."""
body = rows[1:]
plane = LEVEL_CELL.match(body[0][0]).group(1)
names = {
"I": "Identity", "A": "Authorization", "E": "Enforcement",
"P": "Placement", "R": "Retention",
}
rungs = []
for cells in body:
match = LEVEL_CELL.match(cells[0])
if not match:
continue
n = int(match.group(2))
text = cells[1] if len(cells) > 1 else ""
rungs.append(
f'<div class="rung r{n}"><span class="code">{plane}{n}</span>'
f'<span class="txt">{inline(text)}</span></div>'
)
while len(rungs) < 5:
rungs.append('<div class="rung na"><span class="code">—</span>'
f'<span class="txt">Ladder ends at {plane}{len(rungs) - 1}.</span></div>')
return (
'<div class="ladder"><div class="pname">'
f'{names.get(plane, plane)}<span>plane {plane}</span></div>'
f'<div class="rungs">{"".join(rungs)}</div></div>'
)
def render_threat(rows: list[list[str]]) -> str:
head = "".join(f"<th>{inline(c)}</th>" for c in rows[0])
body = []
for cells in rows[1:]:
tds = [f"<td>{inline(cells[0])}</td>"]
for c in cells[1:]:
cls = "yes" if "" in c else "no" if "" in c else ""
tds.append(f'<td class="{cls}">{inline(c)}</td>')
body.append(f"<tr>{''.join(tds)}</tr>")
return (f'<div class="scroll"><table class="tm"><thead><tr>{head}</tr></thead>'
f'<tbody>{"".join(body)}</tbody></table></div>')
def render_matrix(rows: list[list[str]]) -> str:
"""`E \\ P` table becomes the two-axis grid. Cells hold pins or markers."""
cols = rows[0][1:]
cells = ['<div class="mgrid">']
for cells_row in rows[1:]:
e = cells_row[0].strip("*")
level = int(e[1]) if len(e) > 1 and e[1].isdigit() else 0
cells.append(f'<div class="rlab">{html.escape(e)}</div>')
for value in cells_row[1:]:
v = value.strip()
if v == "":
cells.append('<div class="mcell void"></div>')
continue
tint = f" tint{level}" if level else ""
pins = ""
for entry in (p.strip() for p in v.split("<br>") if p.strip()):
ghost = " ghost" if entry.startswith("(") else ""
pins += f'<span class="pin{ghost}">{inline(entry.strip("()"))}</span>'
cells.append(f'<div class="mcell{tint}">{pins}</div>')
cells.append('<div class="rlab"></div>')
cells.extend(f'<div class="clab">{html.escape(c)}</div>' for c in cols)
cells.append("</div>")
return (
'<div class="matrix-shell"><div class="ylab">Enforcement →</div>'
+ "".join(cells)
+ "</div>"
'<div class="mnote">'
'<span class="k"><span class="sw"></span>Where a service sits today</span>'
'<span class="k"><span class="sw g"></span>Target or default</span>'
'<span class="k"><span class="sw v"></span>Unreachable at this placement</span>'
"</div>"
)
def render_table(rows: list[list[str]]) -> str:
if not rows:
return ""
header = [c.strip() for c in rows[0]]
first = header[0].lower()
if first.replace(" ", "") in {"e\\p", "e\\p"}:
return render_matrix(rows)
if first == "threat":
return render_threat(rows)
kind_col = header.index("Kind") if "Kind" in header else None
# A level table is a ladder. The evidence table also leads with `Level` but
# carries a `Kind` column, and is a table of artifacts, not of rungs.
if first == "level" and kind_col is None and len(rows) > 1 and LEVEL_CELL.match(rows[1][0]):
return render_ladder(rows)
head = "".join(f"<th>{inline(c)}</th>" for c in header)
body = []
for cells in rows[1:]:
tds = []
for i, c in enumerate(cells):
if i == kind_col:
adv = "adv" if "adversarial" in c.lower() else ""
label = re.sub(r"[*_]", "", c).strip()
tds.append(f'<td><span class="kind {adv}">{html.escape(label)}</span></td>')
else:
tds.append(f"<td>{inline(c)}</td>")
body.append(f"<tr>{''.join(tds)}</tr>")
return (f'<div class="scroll"><table><thead><tr>{head}</tr></thead>'
f'<tbody>{"".join(body)}</tbody></table></div>')
# --- document ---------------------------------------------------------------
def render_body(markdown: str) -> tuple[str, list[tuple[str, str, str]]]:
lines = markdown.splitlines()
out: list[str] = []
rail: list[tuple[str, str, str]] = []
open_section = False
ladders_open = False
i = 0
def close_ladders() -> None:
nonlocal ladders_open
if ladders_open:
out.append("</div></div>")
ladders_open = False
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
heading = HEADING.match(stripped)
if heading:
level, text = len(heading.group(1)), heading.group(2)
if level == 1:
i += 1
continue
if level == 2:
close_ladders()
if open_section:
out.append("</section>")
match = SECTION_NO.match(text)
if match:
num, title = match.group(1), match.group(2)
anchor = f"s{num}"
rail.append((anchor, num, title))
out.append(
f'<section id="{anchor}"><h2>'
f'<span class="sn">{int(num):02d}</span>{inline(title)}</h2>'
)
else:
anchor = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
rail.append((anchor, "·", text))
out.append(f'<section id="{anchor}"><h2>{inline(text)}</h2>')
open_section = True
else:
close_ladders()
out.append(f"<h3>{inline(text)}</h3>")
i += 1
continue
if stripped.startswith("|"):
rows, i = parse_table(lines, i)
rendered = render_table(rows)
if 'class="ladder"' in rendered:
if not ladders_open:
out.append('<div class="breakout"><div class="ladders">')
ladders_open = True
out.append(rendered)
else:
close_ladders()
out.append(rendered)
continue
close_ladders()
if stripped.startswith("```"):
block = []
i += 1
while i < len(lines) and not lines[i].strip().startswith("```"):
block.append(lines[i])
i += 1
out.append(f"<pre>{html.escape(chr(10).join(block))}</pre>")
i += 1
continue
if stripped.startswith(">"):
quote = []
while i < len(lines) and lines[i].strip().startswith(">"):
quote.append(lines[i].strip().lstrip(">").strip())
i += 1
out.append(f'<div class="rule-quote"><p>{inline(" ".join(quote))}</p></div>')
continue
if re.match(r"^[-*]\s+", stripped) or re.match(r"^\d+\.\s+", stripped):
ordered = bool(re.match(r"^\d+\.\s+", stripped))
items = []
while i < len(lines):
s = lines[i].strip()
if re.match(r"^[-*]\s+", s) or re.match(r"^\d+\.\s+", s):
items.append(re.sub(r"^([-*]|\d+\.)\s+", "", s))
elif s and lines[i].startswith((" ", "\t")) and items:
items[-1] += " " + s
else:
break
i += 1
tag = "ol" if ordered else "ul"
body = "".join(f"<li>{inline(t)}</li>" for t in items)
out.append(f"<{tag}>{body}</{tag}>")
continue
if set(stripped) <= set("-") and len(stripped) >= 3:
i += 1
continue
para = [stripped]
i += 1
while i < len(lines) and lines[i].strip() and not re.match(
r"^(\||>|```|#{1,4}\s|[-*]\s|\d+\.\s|---)", lines[i].strip()
):
para.append(lines[i].strip())
i += 1
text = " ".join(para)
decision = DECISION.match(text)
if decision:
out.append(
f'<p><span class="dec">{inline(decision.group(1))}</span>'
f"{inline(decision.group(2))}</p>"
)
else:
out.append(f"<p>{inline(text)}</p>")
close_ladders()
if open_section:
out.append("</section>")
return "\n".join(out), rail
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("source", type=pathlib.Path)
ap.add_argument("--output", required=True, type=pathlib.Path)
ap.add_argument("--title", default=None)
ap.add_argument("--subtitle", default="")
args = ap.parse_args()
meta, markdown = split_frontmatter(args.source.read_text())
body, rail = render_body(markdown)
title = args.title or meta.get("title", args.source.stem)
display = title.split(":")[0].strip()
eyebrow = " ".join(
f"<span{' class=\"stat\"' if k == 'status' else ''}>{html.escape(v)}</span>"
for k, v in (
("id", meta.get("id", "")),
("status", f"{meta.get('status', '')} · {meta.get('revision', '')}".strip(" ·")),
("date", meta.get("date", "")),
)
if v
)
rail_html = "".join(
f'<li><a href="#{a}"><span class="n">{n}</span>{html.escape(t)}</a></li>'
for a, n, t in rail
)
page = (
f"<title>{html.escape(display)}</title>\n"
f"<style>\n{STYLE.read_text()}\n</style>\n"
'<div class="wrap"><header>'
f'<div class="eyebrow">{eyebrow}<span>generated from canon — do not edit</span></div>'
f"<h1>{html.escape(display)}</h1>"
+ (f'<p class="sub">{html.escape(args.subtitle)}</p>' if args.subtitle else "")
+ '</header><div class="layout">'
f'<nav class="rail" aria-label="Sections"><ol>{rail_html}</ol></nav>'
f"<main>{body}"
f'<footer><span>{html.escape(meta.get("id", ""))} · '
f'{html.escape(meta.get("revision", ""))} · {html.escape(meta.get("status", ""))}</span>'
"<span>generated from the-custodian/canon</span></footer>"
"</main></div></div>\n"
)
args.output.write_text(page)
print(f"{args.output}: {len(rail)} sections, {len(page)} bytes")
return 0
if __name__ == "__main__":
raise SystemExit(main())