Repository navigation
Open
Container
20 / 2220 of 22 issues completed
Copy link
Description
The tools no maintained tool covers move from hal to the platform: the legal pages, person export and erasure, AI keys and their expiry, and the secret expiry checks.
Caller
- Platform on k3s and Flux: from an empty PC to every app live, with hal archived #1, outline row P9: "Legal pages, person export and erasure, AI keys and their expiry, and the secret expiry checks have moved from hal." and its guiding sentence, which names these as the few tools no tool covers.
- hopin#493 and #495: composing the legal pages from app releases needs an owner here.
The cut is the comment below; each slice gets its files and dependencies at its pull.
Invariants every slice keeps
- Nothing a maintained tool does is built here; each piece names what it was checked against.
- hal's copy of a tool leaves with hal (P11), and until then only one of the two serves a given address.
- The legal text is the operator's: sections move word for word, a changed sentence is his.
- Commands print names, counts and outcomes; a person's data or a key's value is never printed.
- An irreversible step inside a command (erasure) asks before it acts.
Slices
| Slice | Done when | Order |
|---|---|---|
| P9a-5 Live go | The tunnel line and hal's apex move, his sentence stands, hal's exit is named, the live body equals built/, and an item on marketplace's board drops the legal-texts skill's sentence that hal still assembles and serves the pages (marketplace#561's follow-up). |
after his wording |
| P9a-6 KI-Dienst | The shared section „Agent-Dienst“ is „KI-Dienst“ with the anchor ki-dienst and every link points there; the Keycloak section's token sentence ends „der KI-Dienst bekommt diese Tokens nicht (siehe „KI-Dienst“).“; the app texts that name or link the section (hal#1086 names songmaker's and presentator's) come from releases that link ki-dienst; hal's copy keeps today's name. |
after P9a-5 and those apps' releases |
| #226 P9c-2 Admin budget command | A new admin's unlimited AI budget is one command of his per admin. | after #202 |
P9d-1 people:export |
One command exports a person from every app that answers the people routes. | after hopin's release that admits a CLI token and #116's step 5 |
P9d-2 people:erase |
One command erases a person in hal's order and reports counts. | after P9d-1 |
| #255 P9f-2 A missed scheduled run | A CronJob that does not start when due reaches Alertmanager, once the CronJobs and kube-state-metrics agree on the time zone; at its pull, a deadline on the restore test is weighed against the rule. | next |
| P9f-3 An expiring secret | An expiring token, or an expiry check that has not passed lately, reaches Alertmanager. | after his choice below and the first Secret that carries its date |
Ruled by the operator, 08.11.2026 („alles nach empfhelung“, on the head's list k to n and q)
- P9a: the legal pages move as soon as the platform builds them, before the big cutover.
- P9a: the general text gains the sentence that logs are kept at most 30 days.
- P9c: a new admin's unlimited AI budget is one command of his per admin.
- P9d-2: no register of erased people. After a restore he runs the erasure again; the privacy text names the three months.
- Every token that expires at its provider carries its date beside it,
ghcr-pullfirst, and the check reports it 30 days ahead.
Ruled by the operator in hal#1086, taken over from hal#1144 on 09.11.2026
- P9a-6: asked whether the shared section stays „Agent-Dienst“ or becomes „KI-Dienst“ or „LiteLLM“, he ruled „4 ja KI-dienst!“: it becomes „KI-Dienst“ with the anchor
ki-dienst, the apps that name or link it change in step, and the Keycloak section's token sentence ends „der KI-Dienst bekommt diese Tokens nicht (siehe „KI-Dienst“).“ hal's head (hal-af) and platform's agreed that platform renames its own copy once it serves the pages, so hal's frozen copy is not changed twice.
Not yet ruled, asked at the slice's pull
The secrets:check rule in platform-secrets, which #175 left there.
P9f-3: how an expiring secret reaches the mail, since Kubernetes cannot grant reading a Secret's date without its value (the plan's D1, journal of 09.11.2026; his card follows).
version = 1
now = "On 09.11.2026 #274 landed (#275, ebd91f0): skat's legal texts join the platform's legal pages; litellm:admin ran on the PC for the operator's account (felix unchanged); #226 (P9c-2) landed the same day (#229, ffdf585)."
next = "Remaining: the expiry mail (P9f-3), the people export and erase (P9d), and #277; none blocks hal's stop."
done_when = "Legal pages, person export and erasure, AI keys and their expiry, and the secret expiry checks run from this repository, and hal's copies are named in P11's exit."
size = "L"
[[expectation]]
text = "Ein bald ablaufender Zugangsschlüssel erreicht dich als Mail über den bestehenden Alarm-Kanal."
asked_on = 2026-11-09
ruling = "A"
ruled_on = 2026-11-09
card_hash = "sha256:a7a737c561c4aa94fc24100b74e7b37d8f97f8e67bfa669f1a732abe3bfd51e1"
via = "board"
question = "Wie soll dich eine Mail erreichen, wenn ein Zugangsschlüssel bald abläuft?"
problem = "Die Ablauf-Prüfung läuft nur, wenn du sie startest; ein ablaufender Schlüssel fällt sonst erst beim Ausfall auf."
decision_kind = "access"
area = "Alarme"
topic = "Ablaufende Schlüssel"
recommended = "A"
reason = "Bei A darf nichts Neues geheime Werte lesen: Es läuft nur die Prüfung regelmäßig, die du am 08.11.2026 beschlossen hast („Every token that expires at its provider carries its date beside it, ghcr-pull first, and the check reports it 30 days ahead“). Kubernetes kennt kein Recht „nur das Datum lesen“. Deshalb müsste B jeden geheimen Wert lesen dürfen. C mailt an dir vorbei über einen zweiten Weg. Heute trägt noch keiner der 24 Schlüssel im Cluster sein Datum. Gebaut wird daher erst, wenn der erste es trägt."
[[expectation.option]]
name = "Stündliche Prüfung am PC"
effect = "Der PC startet jede Stunde die Prüfung, die es heute schon gibt, mit den Rechten, die sie heute hat. Besteht sie, hinterlässt er ein Lebenszeichen im Cluster. Ist das Lebenszeichen älter als zwei Stunden und bleibt es noch eine Stunde so, kommt eine Mail."
notice = "Du bekommst eine Mail „Ablauf-Prüfung nicht bestanden“. Dahinter steckt ein bald ablaufender Schlüssel, eine gescheiterte Prüfung oder ein stehender Zeitplan; welcher es ist, zeigt dir ein Befehl. Nach einem langen Aus des PCs prüft er gleich nach dem Start wieder."
cost = "Ein kleiner Zeitplan am PC, ein Skript und eine Alarm-Regel. Die Mail nennt den Schlüssel nicht selbst."
undo = "reversible"
undo_how = "Zeitplan und Regel wieder entfernen; das Lebenszeichen bleibt unbenutzt liegen."
contract = "P9f-3 option B of the plan (#173 journal, 09.11.2026) with the review's fixes: an hourly systemd user timer (Persistent=true, a boot run retried until the k3s API answers) runs `mise run check:secrets-expiry` and on a pass renews the Lease `prometheus/secrets-expiry`, which Git declares without renewTime; kube-state-metrics gains the `leases` collector only; the alert is `kube_lease_owner{lease=\"secrets-expiry\"} unless on(namespace, lease) kube_lease_renew_time > time() - 7200` with `for: 1h`. No component gains a right on Secrets. Built when the first Secret carries `platform.hallucinai.de/expires-on`."
[[expectation.option]]
name = "Prüfung im Cluster"
effect = "Ein Job im Cluster prüft selbst jede Stunde und meldet einen bald ablaufenden Schlüssel direkt an den Alarm-Kanal."
notice = "Die Mail nennt gleich den Schlüssel und sein Ablaufdatum."
cost = "Der Job darf dafür jeden geheimen Wert im Cluster lesen. Ein Fehler in ihm oder ein Angriff auf ihn legt alle Schlüssel offen. Das braucht eine strengere Prüfung."
undo = "reversible"
undo_how = "Job und seine Rechte wieder entfernen."
contract = "P9f-3 option C: a CronJob under a ServiceAccount with `list` on `secrets` cluster-wide runs the expiry check and exports one series per annotated Secret; material risk (rights over Secrets), with a cross-provider final gate."
[[expectation.option]]
name = "Mail von GitHub"
effect = "Die Prüfung läuft regelmäßig im privaten Schlüssel-Repository bei GitHub. Schlägt sie fehl, mailt GitHub."
notice = "Die Mail kommt von GitHub, nicht von deiner Alarm-Adresse, und sie kommt auch, wenn der PC aus ist."
cost = "Ein zweiter Alarmweg neben dem bestehenden. Er sieht nur, was im Repository steht, nicht, was im Cluster liegt."
undo = "reversible"
undo_how = "Den geplanten Lauf im Repository wieder entfernen."
contract = "P9f-3 option E: a scheduled GitHub Actions workflow in platform-secrets reads the annotations of the committed Secrets (plain metadata beside sops-encrypted data) and fails on a date within 30 days; GitHub's failure mail is the channel."
[[expectation.option]]
name = "Wie heute"
effect = "Es gibt keine regelmäßige Prüfung. Du startest sie selbst, wenn du willst."
same_as_today = true
notice = "Du bekommst keine Mail. Ein ablaufender Schlüssel fällt auf, wenn du prüfst oder wenn etwas ausfällt."
cost = "Nichts zu bauen. Das Risiko eines unbemerkten Ablaufs bleibt."
undo = "reversible"
undo_how = "Eine der anderen Wahlen später nachholen."
contract = "P9f-3 is dropped from #173; `mise run check:secrets-expiry` stays a manual command."
[[expectation]]
text = "Der allgemeine Teil der Datenschutzerklärung bekommt einen Satz dazu, wie lange Server-Protokolle aufbewahrt werden."
asked_on = 2026-11-09
ruling = "other"
ruled_on = 2026-11-09
card_hash = "sha256:e51d511a0dfc59046790998c5dec05c14fce18ce3d4f66c143c542c8f67ad4bf"
via = "chat"
other = "sollten die apps die fristen schreiben oder wir global und die apps halten sich dran! — also global: der allgemeine Teil nennt die Frist, die Apps halten sich daran."
question = "Welcher Satz kommt in den allgemeinen Teil, bevor die Rechtsseiten von der neuen Plattform ausgeliefert werden?"
problem = "Zwei App-Texte nennen für Server-Protokolle keine Frist; der allgemeine Teil sagt dazu bisher nichts."
decision_kind = "wording"
area = "Datenschutz"
topic = "Server-Protokolle"
recommended = "A"
reason = "Wir bauen nichts neu. Die Abschnitte sind hals Text Wort für Wort, und die Pipeline prüft das. Neu ist nur dein Beschluss vom 08.11. Der Server löscht jede Protokollzeile, bevor sie 30 Tage alt ist: die alten Dienste über hals tägliche Bereinigung des Systemprotokolls, die neue Plattform über ihre eigene tägliche Bereinigung und die 28-Tage-Frist ihres Log-Speichers. Der Wortlaut übernimmt den Satz, den die Abschnitte zu Keycloak und zum Sprachdienst schon haben."
[[expectation.option]]
name = "Ein Satz wie bei Keycloak"
effect = "Der allgemeine Teil sagt: „Was unsere Dienste auf dem Server ins Protokoll schreiben, löschen wir, bevor es 30 Tage alt ist; war der Server ausgeschaltet, kurz nach seinem nächsten Start.“ Danach geht die Seite von der neuen Plattform live."
notice = "Auf der Datenschutzseite steht der Satz im allgemeinen Teil, mit neuem Stand."
cost = "Songmaker und Presentator schreiben heute „keine Frist“. Ihre Köpfe gleichen das in ihrer nächsten Version an."
undo = "reversible"
undo_how = "Den Satz in einem neuen Stand wieder streichen."
contract = "P9a-5: infrastructure/legal/sections/allgemein/datenschutz.html gains the sentence verbatim, with a new stand.toml date. Platform's copy becomes the sections' owner, and the equality check against hal's sections is dropped in the same slice. songmaker's and presentator's heads each get an item to align their log sentence."
[[expectation.option]]
name = "Kein neuer Satz"
effect = "Die Seite geht mit hals Text unverändert live. Wie lange Protokolle bleiben, steht dann nur in den einzelnen Abschnitten."
notice = "Am Text ändert sich nichts."
cost = "Bei zwei Apps bleibt „keine Frist festgelegt“ stehen, obwohl der Server nach 30 Tagen löscht. Dein Beschluss vom 08.11. fällt weg."
undo = "reversible"
undo_how = "Den Satz später nachtragen."
contract = "P9a-5 goes live with hal's sections byte for byte; the ruling of 08.11.2026 that the general text gains the 30-day sentence is withdrawn."
Metadata
Metadata
Assignees
Labels
No labels
Activity
FlexOr2 commentedon Nov 8, 2026
Read-only cut by the phase planner, 08.11.2026, at platform f8e6508. It is evidence for the slices; each slice's files and dependencies are fixed at its pull.
P9 Tools and presentator's web: a read-only cut
Nothing was run against a cluster, no Secret, dump or key read. Every claim cites file:line or a URL; "unconfirmed" marks what I could not check.
1. Each tool today, and where it should live
1a. Legal pages
Today.
hal apply --localbuilds/impressum,/datenschutz,/nutzungsbedingungenfromlegal/frame/(skeleton + 6 block templates), hal's own sectionslegal/sections/{allgemein,keycloak,litellm,speech-runtime}/and each app'slegal/datenschutz.html(+ optionalnutzungsbedingungen.html) read from~/srv/<key>/.gitat the shown release's commit (/home/felix-hummert/git/hal/src/hal/stack/legal_step.py:1-8, 272-290). A host-only app (norepo) is read at HEAD of its checkout (legal_step.py:293-307; hal#1148 uses this for skat). The pages are written to git-ignoredlegal/built/and served bynginxinc/nginx-unprivilegedon an internal network with a strict CSP andCache-Control: no-cache(hal/compose.yaml:184-205,hal/legal/nginx/default.conf). Assets: appkit's tokens and fonts are copied from the installed wheel intolegal/assets/appkit-<version>/, served at/_rechtliches/appkit-<version>/(hal/src/hal/stack/appkit_step.py:1-10;hal/pyproject.toml:9pins appkit 0.15.0 from a GitHub release wheel). Validator: an allow-list grammar (h2 h3 section p ul ol li strong em code br a), 64 KiB max, canonical form or refusal, links only to own ids or STABLE_IDS (legal_section.py:1-20,legal_pages.py:30-48, 243-266).hal legal KEY [DIR]checks an app's files the same way (legal_commands.py). Apex host: hal's catalog serves the apex paths via its tunnel and Cloudflare redirects everything else (hal/fleet.yaml:3-7,36-40;docs/architecture.md:186).Callers. hopin (
legal/since hopin#493, release 2026.11.08.1 runs on the platform:apps/hopin/hopin.yaml:34), presentator, songmaker, skat (each haslegal/datenschutz.htmlandnutzungsbedingungen.html; confirmed byls), every app's footer linkshallucinai.de/{impressum,datenschutz#<app>,nutzungsbedingungen#<app>}(hopin#495). hal#1148 (skat's legal block on hal) says it closes as overtaken if P9 lands first.State. No personal data. Public HTML only. The legal wording is the operator's (hopin#493 card).
Where on the platform. A stateless app
apps/legal/: nginx-unprivileged (new vendor entry) serving the three pages and/_rechtliches/from ConfigMaps, built by a shipped commandmise run legal:buildthat regenerates committedapps/legal/built/*.html(checked bytests/generated/generated_files_match_regeneration.sh, the pattern ofcharts:render,AGENTS.md:13-16,180-181). The composer reads each app's two files at the tag of the ref inapps/<app>/*.yamlfrom GitHub (all four app repos are public:gh api repos/overnightworks/<app> --jq .private→ false), the wayscripts/verify_image_release.sh:402-419already resolves tags. Route: Ingress hosthallucinai.dewith the five served paths; a tunnel line for the apex; hal's catalog getstunnel: platformon the apex as hopin did (hal#1100 pattern, hal#1148 "Freeze").imagesjob would refuse an unattested ref (AGENTS.md:139-150).docs/app.md"Description") and needs no network at build; rejected only because the repos are public and one owner at one tag is hal's exact semantics. Keep as fallback if a repo turns private.hal applyfrom~/srv/<key>(hopin#493 "now": pages show no #hopin until his apply). P9a ends that.legal_pages.py299,legal_section.py421, ~150 lines oflegal_step.py, tests 2.5k lines) moves once (invariant 3). Platform has no Python inmise.toml:6-15; the move adds a pinnedpythontool used only in CI and on a dev machine.1b. Person export and erasure
Today. Not built on hal: hal#256 is OPEN ("Those two commands are not built yet",
hal/docs/people.md:6-9); hal#680 (hal login) closed NOT_PLANNED 06.11.2026 "identity tooling belongs to platform#1 P7"; noLOGIN/PEOPLEverb inhal/src/hal/output/command.py:9-26. What exists: the app-side contract (docs/people.md:GET /api/admin/people/{sub}/exportZIP,DELETE …/{sub}[?dry_run], idempotent, 401/403 by webauth BearerGate,aud= exactly the app's client,azp= the CLI's client id) and implementations in hopin (hopin/src/hopin/people.py) and presentator (presentator/src/presentator/api/people.py); songmaker's and skat's not found by grep (unconfirmed). hal's order (people.md:108-134): register sub → lock in Keycloak → dry-run all → erase all → dry-run all → delete Keycloak user; tombstone register replayed after restores.State touched: personal data. Every app's data about one
sub, Keycloak's user row, and (on export) the person's log lines (hal#256: an Art. 15 answer covers journal lines too; on the platform pod logs live 30 days,AGENTS.md:113-118).Where. Shipped commands
mise run people:export <sub>andpeople:erase <sub>, run by the operator with his own token (the contract forbids a service account:people.md:33-35). Needs in the realm a public client (platform-cli, device grant RFC 8628,access.token.header.type.rfc9068, refresh-token rotation per hal#256 "Also binding") and one audience scope per app (hal#256 "exactly one audience"; webauth#516 measured audience+scope). Keycloak lock/delete runs throughkcadminside Keycloak's container askeycloak:move-groupdoes (platform/mise.toml:604-640, "no secret reaches this shell").1c. AI keys and their expiry
Two things sit under this row.
Keys and budgets. hal's key step makes each app's LiteLLM key (value = the app's file
~/.secrets/litellm/apps/<app>, found by SHA-256), the person budget (max_end_user_budget_id, 2 $/1mo), per-appmax_budget10 $ andmax_parallel_requests5, the three routes andmodelsfrom/v1/models, and links the customer rows of/services/litellm/adminmembers to the unlimited budgetlitellm-admin(hal/src/hal/stack/litellm_keys.py:1-28, 58-59;hal/src/hal/stack/api_limits.py:14-16;hal/docs/agent-service.md"The key step", "The admin group's rows"). Caller:hal apply. State: LiteLLM's Postgres (key hashes, budgets, customer rows with person fingerprints = pseudonymous personal data).On the platform after P7c (#159, PR #163) the DB is hal's copy and holds hal's keys; each app's key value reaches the app as a file from platform-secrets (hopin's manifest already names
/run/secrets/litellm_key,apps/hopin/hopin.yaml:52-55; cutover step 4 in #116 copies hopin'slitellm_key). So the platform needs: (i) a new app's key (skat, agents' runner), (ii) budget/limit changes, (iii) admin rows for a new admin. LiteLLM has no declarative key config (itsconfig.yamlcarries models and settings only; hal's docs confirm keys live only in the DB), so this is one of the "few tools no tool covers" named in #1's guiding sentence.kubectl execinto the LiteLLM pod, which holdsLITELLM_MASTER_KEY(PR LiteLLM runs in the layer litellm behind hal's front with its egress closed #163 mounts Secretlitellmkeymaster_key):litellm:key <app>generates a key from a declared tableinfrastructure/litellm/keys.yaml(alias = app, budget, parallel, models, routes) and writes the value once to a 0600 file the operator encrypts into platform-secrets (thekeycloak:move-groupfile pattern,mise.toml:604-612);litellm:keysreconciles budgets/limits/models of existing keys found by alias (/key/list→ hash →/key/update) and prints names and counts only. Admin rows:litellm:admin-row <sub>by hand, decision 3.Expiry (
hal expire). Daily 04:00hal-expire.timer, one all-or-nothing SQL statement deleting daily person/tool/label rows older than 90 UTC days, tool rows unused 90 days, and customer rows idle 90 days with spend 0 and no budget (hal/src/hal/litellm_expiry.py:14-17, 87-110;hal/src/hal/workstation/jobs.py:86-97). 90 is "not a setting: the privacy text states this period" (litellm_expiry.py:17; the text promises "spätestens 92 Tage",hal/legal/sections/litellm/datenschutz.html). State: LiteLLM's Postgres, pseudonymous personal data (fingerprints).litellmat 04:00 runningpsqlfrom the Postgres vendor image against Servicepostgreswith the Secretpostgrespassword and the statement as a ConfigMapexpire.sql, printing five counts; Postgres's NetworkPolicy already admits any pod of the namespace (infrastructure/postgres/postgres.yaml:124-131). Checked against: LiteLLM's ownmaximum_spend_logs_retention_period(covers spend logs only, 30 days, not these tables:agent-service.md"What a call leaves behind"); pg_cron (not in the official image). Alert on failure is P5's standard job alert; until P5,kubectl get jobs -n litellmand the k3d cell.1d. The secret expiry checks
Today. Less than the row suggests.
hal secrets/dailyhal checkread~/.secretsbystat, verifygh auth status, and printexpiry unknownfor every entry (hal/src/hal/workstation/secrets_view.py:752-754;hal/docs/home.md:285"heute immer expiry unknown"). The only expiry data isexpires_in_days = 366on the two Cloudflare token entries used to pre-fill the provider's form at creation (secrets_manifest.toml:56,84;secrets_setup.py:366-375). No check computes a date. Callers: the dailyhal-checktimer (jobs.py:74-85). State: no personal data; file names and modes.What expires on the platform.
ghcr-pull(a GitHub token with a GitHub-side expiry; its kind is unconfirmed), Cloudflare API tokens once #48 moves the zone here, SonarCloud tokens (quality's, not platform's). Not expiring: the age key, the deploy key, the tunnel token, Keycloak client secrets, LiteLLM keys, provider keys.Where. Metadata beside the value: an annotation
platform.overnightworks.de/expires-on: YYYY-MM-DDon the Secret manifest in platform-secrets. sops encrypts onlydata|stringDatathere (platform-secrets/.sops.yaml:encrypted_regex: ^(data|stringData)$), so the annotation is plaintext and readable in-cluster without decrypting anything. A shippedmise run check:secrets-expiry(thecheck:pod-logspattern,AGENTS.md:29-34) lists Secrets with the annotation and fails naming those within 30 days or past; the same script later runs as a CronJob whose failure P5 alerts. Checked against: kube-state-metrics (exports annotations only if allow-listed; a Prometheus rule onkube_secret_annotationsis the P5-era form), x509-certificate-exporter (certificates only, none here).github-authentication-token-expirationheader,hal/docs/architecture.md:427) from a pod mountingghcr-pull; truthful but reads the value in a pod and covers GitHub only. The annotation covers every provider with one rule; its cost is that the operator writes the date when he writes the value (platform-secrets'secrets:checkcan require it for token-kind Secrets; unconfirmed what that check does today).2. The cut
Order: P9a → P9b, P9c, P9e in parallel → P9d after P7b's realm and a webauth-admitting app on the platform. Titles are proposed item titles; "cells" name CI (k3d) versus PC.
apps/legalon the platform from hal's frame and sectionsapps/legal/legal.yaml(Deployment nginx-unprivileged, Service, Ingress host apex with 5 paths, NetworkPolicy from traefik),apps/legal/kustomization.yaml(configMapGenerator: built pages, nginx conf,legal-2.css; binaryData for appkit fonts),apps/legal/frame/,apps/legal/sections/{allgemein,keycloak,litellm,speech-runtime}/,apps/legal/built/(generated),src/legal/(composer moved:legal_section.py,legal_pages.py, a thinbuild.py) + its tests,mise.toml(legal:build,python),clusters/pc/apps.yaml,infrastructure/namespaces/namespaces.yaml,infrastructure/admission/image-vendors.yaml(nginx-unprivileged),infrastructure/cloudflared/tunnel lineagent-dienstuntil the cutover window, thenki-dienstwith the apps in stepcloudflaredgets 200 for the three paths and the CSS/fonts with Host apex, 404 for/,/_rechtliches/directory and an unlisted path; the CSP header equals hal's;legal:buildregenerates byte-equal pages (generated cell); a section that breaks the grammar failslegal:buildnaming file and line, never quoting text. PC: Flux Ready; the operator's halfleet.yamlapex host getstunnel: platformand the live page's body hash equals the committed built page (hal#110 check C1)legal/at its ref are served live at the three addresses with hal's look; hal'slegalcontainer no longer serves themlegal_pages.py:1-7), and nginx without pages proves nothing. Moving the composer is ~750 production lines plus tests; the manifests ~120. The app blocks (hopin at its ref) come with it, since the composer reads refs fromapps/; without them the page would lie by omission (hopin is live). Songmaker, presentator and skat get their blocks when their refs appear underapps/.hal expireruns as the CronJoblitellm-expireinfrastructure/litellm/expire.yaml(CronJob 04:00,concurrencyPolicy: Forbid, restricted, psql from the postgres vendor image),infrastructure/litellm/expire.sql(viakustomization.yamlconfigMapGenerator),tests/litellm/expire_deletes_only_what_is_past_90_days.shhal/legal/sections/litellm/datenschutz.html); hal#945restrictedand the image policy. PC:kubectl create job --fromonce after C3's load; counts onlylitellm, the cell holds, and after its first PC run the counts are reportedlitellm:keyandlitellm:keysinfrastructure/litellm/keys.yaml(ConfigMap: per-app alias, budget, parallel, routes; person budget; the same numbers as hal's defaults),mise.toml(litellm:key,litellm:keys),tests/litellm/declared_keys_match_live_keys.shapi_limits.pydefaults;agent-service.md"The key step"litellm:key demoagainst the mock LiteLLM makes one key with the declared fields and writes a 0600 file whose content is never printed;litellm:keysafter a changed budget updates by alias and prints1 changed; a second run prints0. PC: operator runslitellm:key skatonce; the file goes into platform-secrets by his handpeople:export <sub>collects one person's export from every app with the operator's own tokeninfrastructure/keycloak/realm.yaml(public clientplatform-cli, device grant,at+jwt, refresh rotation; one audience scope per app client),mise.toml(people:login,people:export),tests/keycloak/platform_cli_token_has_one_audience.shhal/docs/people.md(the contract), hal#256 "Rulings that bind hal's tokens" 1-4, agent-runtime#672 line 18 A pattern (#160)people-hopincarriesaud= hopin only,typ at+jwt,azp platform-cli; hopin's export route answers 200 and a ZIP for a subject with no data (people.md:59-60) and 401 for a token with anotheraud. PC: the operator signs in once and exports his own accountTOKEN_CLIENTS/azpsetting namesplatform-cli(app-side change, hopin's board)people:erase <sub>erases one person across the apps and Keycloak in hal's ordermise.toml(people:erase: lock via kcadm, dry-run, erase, dry-run, delete user),tests/keycloak/erase_order_holds_on_a_throwaway_user.sh,docs/app.md(checklist line: which apps answer the people routes)people.md:108-134steps 1-5 minus the register (decision 4); hal#256's "withdraw on decline" rule500 leftoverends after 3 tries naming kinds, never names; a decline before erasure changes nothing. PC: once on a throwaway usercheck:secrets-expirynames those within 30 daysmise.toml(check:secrets-expiry),scripts/check_secrets_expiry.sh,tests/secrets/expiry_check_names_each_breach.sh(fake kubectl); platform-secrets: the annotation onghcr-pullandsecrets:checkrequiring it on token kinds (its own PR there)platform-secrets/.sops.yaml(metadata plaintext)mise run check:secrets-expiryoncelitellmand the checkpresentator's web (P6 row, #43)
The row is really "presentator moves", stateful with a sealed worker; P7's U12 and #43's R5 already say it is its own item. Cut:
no-network(#68, exists)host/k3s/(containerd handler config, dummy CNI pinned),infrastructure/runtimeclass/,tests/k3s/lo; a default-class control has a route. PC: the host step (containerd config = k3s restart: Invariant 1 says a step that would stop hal's containers waits for his word; a k3s restart does not touch Docker, but say it once)apps/presentatorapps/presentator/presentator.yaml(web Deployment Recreate, uid 1001,Host:probe to/health, 3 PVCsdatabase/builds/mirrors+ RWObuild-socket, env per #43 comment 14:09 incl.PRESENTATOR_TRUSTED_PROXIES=10.42.0.0/16,AGENT__GATEWAY__ORIGIN= platform's front Service,API_GROUP=/services/litellmonce the window lands),apps/presentator/worker.yaml(StatefulSet 1,runtimeClassName: no-network, no SA token, grace > 0, no exec probe/postStart),apps/presentator/rbac.yaml(SA for web, Roledeleteon the one pod byresourceNames),apps/presentator/kustomization.yaml,clusters/pc/apps.yaml,infrastructure/namespaces/namespaces.yaml(baselineenforce,restrictedwarn/audit)docs/app.mdchecklist 6hello-deckbuilds with no network, the worker pod refuses in the default class (no-route), the web passesrestricteddry-run, the worker onlybaseline; the web's SA can delete only its worker podkubernetesseal)apps/presentator/presentator.yaml(Ingress forpresentator.hallucinai.deandpresentator-talk.hallucinai.de, NetworkPolicy from traefik + keycloak on 8000),infrastructure/keycloak/keycloak-egress.yaml(egress to presentator's Service),infrastructure/cloudflared/(two lines,/auth/backchannel-logoutdenied at the edge as hal'sdeny_public), CoreDNS rewrite if U8's fallback is neededcloudflared, no answer from another namespace; Keycloak's pod reachespresentator:8000. PC: halfleet.yamlgetstunnel: platformon both hosts (hal frozen takes this rename)mise.toml(presentator:load-database <file>: copies hal's SQLite online copy into thedatabasePVC with the web at 0, likedb:load-dump),apps/presentator/presentator.yaml(k8up annotations:backupcommand= pythonsqlite3.Connection.backupto stdout;buildsdeclared as cache or truth per decision 6),tests/presentator/restored_database_serves_the_deck_list.shbackup/apps.toml:40-47/healthand the deck list; a Backup yields a snapshot with the SQLite stream. PC: operator'shal restore presentator --target, thenpresentator:load-database; builds volume copied once or rebuilt (decision 6)buildsis truth3. Dependencies and what starts today
postgresinlitellm, PR LiteLLM runs in the layer litellm behind hal's front with its egress closed #163's LiteLLM pod and mock), P9c (master key in Secretlitellm, PR LiteLLM runs in the layer litellm behind hal's front with its egress closed #163 line ~767-769). Its C3 (PC load) is the operator's.azp platform-cli(hopin'sTOKEN_CLIENTSor equivalent, presentator'sTOKEN_CLIENTSper P6: hopin, speech-runtime and presentator's web run in k3d and on the PC beside hal #43 comment "Optional") and per-app audience scopes inrealm.yaml; songmaker and skat need the people routes first (unconfirmed for songmaker). P9d-2 needs P9d-1.ki-dienstrename lands in the window with the apps' bumps. P9b/P9c act on the restored copy before the cutover and on live data after; P9c'slitellm:key skatis useful only after skat's move (skat runs on the platform from its attested private image, routed by Traefik #122).apps/presentatorat a platform SHA, so PW1 lands first.litellm_key(cutover step 4), skat's key fromlitellm:key, presentator'ssecret-key,oidc client secret(its realm client already exists:config-cli.yamlPRESENTATOR_CLIENT_SECRET) andlitellm_key;expires-ononghcr-pull; nothing for P9a, P9b or P9d (the operator's token is his, never stored in the repo; the refresh token at 0600 in his config dir).4. Decisions only the operator can make (German, plain)
5. Predecessors and exits on hal
legalunit,legal/tree, the legal and appkit steps,hal legal: leave with hal's archive after P9a's live go; until then hal'shal applykeeps writinglegal/built/for the apex while its tunnel line is hal's. hal#1148's legal half closes as overtaken (its own text says so).hal-expire.timer(jobs.py:86-97): stops at the cutover when hal's LiteLLM stops (P7: Postgres, Keycloak, LiteLLM and backups run on the platform, tried on the PC with copies of hal's backups #116 cutover step 2/8); until then it deletes on hal's copy, the platform's CronJob on the platform's.hal apply,litellm-keysone-off and~/.secrets/litellm/apps/*: leave with hal; their values move into platform-secrets at the cutover.hal secrets/hal checksecret rows: leave with hal; nothing on the platform replaces the~/.secretstree checks, since the tree leaves too.docs/people.mdmoves to platform'sdocs/(the contract is cross-repo and must survive hal).backup/apps.toml[app.presentator]and presentator'scompose.yaml: presentator#865 S8 after PW3.6. Risks and unknowns
emptyDirfilled by an initContainer from the wheel is rejected (network at start); a second ConfigMap per font works.mise.toml:6-15); adding one is a repo-wide precedent. Alternative is rewriting a 420-line HTML grammar validator in bash, which I would not.generatedcell with network:legal:buildfetches app files at tags;charts:renderalready fetches charts in that cell (AGENTS.md:180-181), so the precedent exists; a tag deleted upstream breaks the build, which is a correct refusal.#agent-diensttoday (presentatorlegal/datenschutz.html:11); the composer's link check must allow the old id until the window PR renames section and apps together./api/admin/peoplefor members of/apps/<app>/admin; still a realm-wide client. Head decision, flagged.ghcr-pullkind: whether it is a fine-grained PAT with an expiry or a classic token is unconfirmed; P9e's annotation works either way.builds: truth or cache is the operator's (decision 6); One platform backup path for every app with state: database and files #165's file part is unbuilt.Critical Files for Implementation
src/legal/)infrastructure/litellm/expire.sql)keycloak:move-groupat 604-640 is the pattern for every shipped command of P9c, P9d, P9e, PW3)platform-cliclient and per-app audience scopes for P9d)apps/legalandapps/presentator)FlexOr2 commentedon Nov 8, 2026
Follow-up from #175's review, cosmetic: the step's name in
checksis very long; shorten it when the file is next touched.FlexOr2 commentedon Nov 8, 2026
Plan review of P9a (Opus, read-only, at platform 6f10bdf), 08.11.2026. The owner holds; the plan as written was one unreviewable lane and would have published a wrong page. It is cut into P9a-1 to P9a-5 in the body. Each later slice takes these at its pull.
Weighed. Python in platform (chosen: the one owner where an app's ref bump and its text land in one pull request,
built/as the visible diff, standard library only). Rejected: a shell or awk rewrite (the validator's tests do not port, branches go unmeasured); a release artefact per app (a workflow in four repositories, and platform must validate anyway); Hugo or Zola (raw HTML passes, no refusal); nh3 or bleach (sanitising rewrites text, against invariant 3); appkit (its VISION says it is no home for page layout); a new repository with an image (each sentence becomes a release and a bump, and it no longer lands with the app's ref).What the validator protects. The writers are the operator and his agents, through pull requests. The CSP already stops execution. The validator stops mistakes on a legally binding page, and canonical-or-refusal is what makes "published byte for byte or not at all" true. Keep it whole.
Findings the slices must close.
apps/holds hopin, skat and speech-runtime, while hal'sfleet.yaml:15-24serves songmaker and presentator, and presentator linksdatenschutz#presentator. Going live before the cutover needs a declared source list (key to tag or sha) that a check ties to the refs underapps/.Standcomes fromgit log -1 --format=%cs(legal_step.py:326-334), which a generated page cannot hold. A declared date per section file, committed and checked. Its form is printed on a legal page, so the operator sees it first.apps/legalbreaksdocs/app.md(a vendor nginx is no release-path image). It goes toinfrastructure/legal/.pyproject.tomlwould make the shelltests/a judged tree (test-layout, quality v0.10.0docs/rules.md:282-290). It needs a nested project with its own tests, a pytest and coverage step, thecoverage-artifactinput and a Sonar scope.bootstrap_pc.sh --only toolswould install the python tool on the PC too.hal legalneeds a successor,legal:check <key> <dir>, the section contract moved to platform's docs, and thelegal-textsskill's pointer updated.presentator/quality/config.toml:60names hal too.fingerprint_pages,BlockFingerprint,PageFingerprintorLegalPlan;git diff built/replaces them.appkit-0.15.0while appkit is at 0.16.0.zone, and what replaces_catalog_keysand the two-owner refusal.Nothing was run for this review; gate behaviour comes from quality's docs and source at v0.10.0.
FlexOr2 commentedon Nov 8, 2026
Cut of P9a-3 (read-only planner, 08.11.2026), kept here because P9a-3b and P9a-4 take it at their pull.
His hal,Pis platform.P9a-3 cut (read-only; platform main 18aa412, hal main a5c2342722266240a363a2c4e5016ba889ce3c1a, clean)
H = hal, P = platform. Nothing run in hal; no Secret read.
1. What legal:build does here, and what waits
infrastructure/legal/frame/(3 page skeletons + 6blocks/) andinfrastructure/legal/sections/<unit>/<page>.html, builds all three pages with the ported assembler (checks ids and links), writes ONLYinfrastructure/legal/built/impressum.html(the one page the layer serves,kustomization.yamlconfigMapGenerator). datenschutz and nutzungsbedingungen stay in memory: without app blocks they would be wrong pages.$field(H legal/frame/impressum.html), so build_pages with no blocks returns the frame bytes. Pbuilt/impressum.htmlis already byte-identical to H's current frame (cmp: IDENT). H's ownlegal/built/is stale (appkit-0.8.0, tabs/footer), is git-ignored (H .gitignore:16): never the reference. Keepappkit-0.15.0in the frame; the bump to 0.16.0 changes bytes and is P9a-4 (F2).legal/frame/**->infrastructure/legal/frame/**;legal/sections/{allgemein,keycloak,litellm,speech-runtime}/*.html(5 files, 34 KB) ->infrastructure/legal/sections/<unit>/<page>.html, bytes unchanged.app_blocks,_release_blocks,_head_of,_shown_release_blocks), source list and B1, appkit assets +legal-2.css, app-file dates, writing datenschutz/nutzungsbedingungen, the two-owner refusal (see 3). P9a-5: tunnel line, apex, his 30-day sentence (B3), live body == built/.section.pylacksparse_general(P AGENTS.md:570-571 says so; H legal_section.py:362-383). This slice ports it with its tests (H tests/test_stack_legal_section.py:336+). Otherwise section.py equals H's.2. One lane or two: two
One lane ports ~230 lines of pages code, ~90 of build, parse_general, 5 section files and 2.3 KB of tests, plus a decision the operator reads (Stand). Split:
src/legal/src/legal/pages.py,build.py,mise.tomltasklegal:build, frame files moved, tests (test_pages.py,test_build.py),tests/generated/generated_files_match_regeneration.sh(+mise run legal:build; it already coversinfrastructure/and git-ignore, ci.yml:161), AGENTS.md. Done when:mise run legal:buildleavesgit diff built/empty and CI's regeneration step passes with it. Production lines well past 100 (verbatim move; say so in dispatch).legal/build.py(reads sections, stand),parse_generalintosection.py, sections moved,infrastructure/legal/stand.toml,tests/legal/sections_equal_hals_at_commit.sh, ci.yml step, docs. Done when: the cell shows the sections equal H at a5c23427, every section has a declared Stand, an undeclared or stale one refuses.3. Part A in detail
TEMPLATES,STABLE_IDS(:30),BlockKind,Block,Frame,frame_*_path,build_pages(:112),_in_page_order,_anchor,_owner_of,_fill*,_render,_page(:228),_owners_of_ids,_ids,_heading_line,_check_links(:275),_german(:298),_MONTHS. From legal_step.py:read_frame(:172),_read(:314, wording "the general part"/"the frame" kept).fingerprint_pages(:123),BlockFingerprint,PageFingerprint,_frame_sha256,_sha256,_anchored_bodies,LegalPlan,PageChange,Change,BlockChange,plan_legal,_change,_replace,read_recorded_page,write_record,block_changes, the apply record (legal_step.py:52-143, 337-421). Output is a plain write of bytes;git diff built/is the change report. hal's 0644fchmodis not needed (ConfigMap).check_appof H (:234): Pcheck.pyhas its own; hal's page build inside it is a P9a-4 question (docs/legal-sections.md:3 says assembled pages are not judged yet, stays true).zone: owner is the host of the Ingress ininfrastructure/legal/legal.yaml(already so for legal:check, mise.toml:415-416; docs/legal-sections.md zone line).legal:buildreads it by the sameyqline and passes it as an argument, no catalog. The one-line yq repeats; judged shorter than a shared helper._catalog_keysreplacement: none needed. A directory undersections/is a unit only if it isallgemeinor a key ofSERVICE_ANCHORS(keycloak anmeldung, litellm agent-dienst, speech-runtime sprachdienst, H :44-48); any other directory is refused naming it (one branch, one test). Two-owner refusal (H :295-306) is not ported: with no app source there is no second owner; P9a-4 must refuse a source-list key that isallgemein/inSERVICE_ANCHORSor has asections/<key>/. Open for P9a-4: how an app file's Stand is declared (B2 applies to apps; H uses git%csof the file at the tag).3b. Stand (B2), part B
Stand: <_german(max block date)>per page (H frame datenschutz.html:19;_pagestand=""). Form:D. Monat YYYY, day unpadded, German names with "März", e.g.8. November 2026. Per page only that page's sections count (H test_stack_legal_pages.py:134,145). Impressum prints none. hal's old built datenschutz shows27. Oktober 2026(stale).infrastructure/legal/stand.toml(outsidesections/, sodiff -ragainst H stays clean), stdlibtomllib, one table per section path:date = 2026-11-05(TOML date) andsha256of the file. Build refuses: a section with no entry, an entry without a file, a sha that differs from the file (message prints the new sha: edit a sentence, update date and sha together). Rejected alternative: date alone, which lets an edited sentence keep an old Stand. Initial dates = H%csat a5c23427: allgemein/datenschutz 2026-11-05, allgemein/nutzungsbedingungen 2026-11-05, keycloak 2026-11-07, litellm 2026-11-07, speech-runtime 2026-11-08. Expected: datenschutz8. November 2026, terms5. November 2026(app blocks may raise it in P9a-4).4. Tests and cells
Block.date), the parse_general tests, and newtest_build.py(unknown section dir, missing/orphan/stale stand entry, only impressum written, missing frame file). Do not port: H test_stack_legal_unit.py (nginx unit, appkit installed-version checks, :179-247 sentence pins: shipped values restated), test_stack_legal_apps.py (P9a-4), test_hal_legal.py, apply/plan rows tests (the apply record).src/legal/pyproject.tomlfail_under 100 branch, source alreadysrc/legal/src/legal; every new branch needs a test,mainof build.py follows check.py (__main__is excluded already). The section.py exclude (return min(text.count) is untouched.tests/legal/sections_equal_hals_at_commit.shinchecksnext to the hopin cell (ci.yml:66-67): hal is PUBLIC (gh repo view: isPrivate false);git init+git fetch --depth 1 <url> a5c2342722266240a363a2c4e5016ba889ce3c1ainto a temp dir, thendiff -rof Hlegal/sectionswithinfrastructure/legal/sections(SHA is the script's named variable). Frame not compared (P9a-4 changes appkit links on purpose). Until P9a-5 the delta is empty; P9a-5 then names the 30-day sentence as the only allowed delta. Impressum regeneration needs no network (generated_files script, cluster job). Network is needed only by the sections cell; no other way to prove equality with hal's commit. Risk: later hal commits to legal/sections diverge from the pin until P11; the head should hold or re-pin.5. Literals and pins a builder changes
built/impressum.htmlis hal'slegal/frame/impressum.htmlbyte for byte"), :246 (cluster job regenerates "gotk-components.yaml,mise.lockand the renders": add the page), :569-571 (section.py"withoutparse_general"; addpages.py,build.py, the new cell), thelegal:command entries (:64-71) getlegal:build, and its Layout line forstand.toml/frame//sections/. docs/legal-sections.md needs no change in A; B may add a line on Stand if it concerns app authors (unknown, likely P9a-4).tests/legal/impressum_through_traefik.shpins the built file's checksum: stays valid only because bytes do not change; that is the proof.src/legal/pyproject.tomlcomments (exclude lines): unchanged.check.pyusage textusage: legal.check <key> <dir> <zone>and mise.toml:408-417: unchanged. New: task namelegal:build, its usage/refusal strings,legal:refusal prefix.infrastructure/legal/kustomization.yamlfiles: built/impressum.html: unchanged..gitignoreof platform ignores nothing here (checked by the generated script's check-ignore).6. Operator's word
None needed. Moves are word for word (invariant 3); initial Stand dates equal hal's, so no printed date changes by decision of ours (rejected: dating all at the move, which would print a false newer Stand). Declare the sha256 variant in the dispatch; the operator sees the printed form
8. November 2026first in P9a-5. Nothing built here goes live: onlybuilt/impressum.htmlis served and it does not change; datenschutz/nutzung reach live in P9a-5.FlexOr2 commentedon Nov 8, 2026
Carried over from overnightworks/hal#110 (line 6 of the head's list of 08.11.2026, left unanswered by the operator: pass the look and the strict rule to the platform). Two things hal will not build:
docs/design/legal-pages.htmlin hal. The closed slice A2 of #110: the legal pages take the blessed S4 look, with an Allgemein block and a back link hal#957 holds its ruled sentences and said to reopen when P9 asks for this look; here it asks.Neither has to wait for the cutover; both are yours to schedule in P9.
16 remaining items
FlexOr2 commentedon Nov 9, 2026
mise run litellm:admin felixran on the PC on 09.11.2026, on the operator's word („3. ja => name "felix"“).Before it, the first k8up Backup of each Postgres namespace ran by hand:
first-backup-20261109in keycloak, litellm, skat and songmaker, allCompleted True Succeeded. The Schedules exist only since 08.11 at 21:55Z, so no weekly run had happened yet.Both runs printed
litellm:admin: felix unchangedand exited 0. felix was already on the admin budget, in the group and on his customer row, so nothing was written.FlexOr2 commentedon Nov 9, 2026
Line 2, ruled 09.11.2026: only the words before „—“ are the operator's. The part after it is the head's reading, which this lane builds: the general part names the 30-day period, and the apps refer to it instead of stating their own server-log period (option A's sentence and its cost).
FlexOr2 commentedon Nov 9, 2026
A risk named by hal's head (agent-runtime#801, G2), checked against the platform's gateway path. It is not credible today.
The risk. LiteLLM synthesises a finish when an upstream stream that is unframed HTTP/1.0 (no chunking, no length) closes before
message_delta. A lost tool call then reads as a complete answer. A chunked HTTP/1.1 cut fails correctly.Why it does not apply here. The platform's path from LiteLLM to a provider is
agent-egress, a TLS passthrough onHostSNI(infrastructure/litellm/egress-allowed.yml,passthrough: true). It never terminates TLS, so it cannot change the HTTP framing, and the provider does not send unframed bodies.When it would become credible: a change that makes
agent-egress, or any proxy between LiteLLM and a provider, terminate TLS and speak HTTP to the provider. That change carries G2 and its test.docs/VISION.md states what the platform promises, for whom, and what …
FlexOr2 commentedon Nov 9, 2026
Three follow-ups from #324's final gate (#289), for P9a-5b:
docs/personal-data.md:16-19and:26-27list "The promise:" without the newallgemein#server-protokolle, so a lane changing retention would miss the one general promise.tests/legal/legal_sections_assets_and_headers_equal_hals_at_commit.sh(run fromci.yml:79) still says "sections" in its name but no longer proves that; rename or delete it with the rest of that check.infrastructure/legal/app-texts/songmaker/datenschutz.html:31says "Audit-Log: wird derzeit ohne feste Frist gespeichert", whileallgemein/datenschutz.html:21says what services write "ins Protokoll" goes before 30 days. The heading and songmaker's own split into Audit-Log and Server-Logs support the narrow reading, but a plain reader sees a contradiction. No diff here fixes it, as the sentence is ruled; its owner is songmaker#1714, and it needs a decision before P9a-5b.FlexOr2 commentedon Nov 9, 2026
Follow-ups of #290's gate on #333, for this item's legal-pages part:
infrastructure/legal/default.conf:1("The headers are hal's") and:40("hal serves no icon") go stale after the go, since The tunnel sends hallucinai.de to Traefik, and the appkit check no longer reads hal #333 removes the comparison with hal and platform's copy becomes the owner. Cleared with hal's exit.host/journald/90-retention.confbacksallgemein#server-protokolle, butdocs/personal-data.mdhas no row for the host journal, so a lane changing it does not find the promise that way. It predates The tunnel sends hallucinai.de to Traefik, and the appkit check no longer reads hal #333 (P2c: the host journal keeps no entry older than 29 days, set by the bootstrap instead of hal's timer #285).FlexOr2 commentedon Nov 9, 2026
From marketplace's head, 09.11.2026, after overnightworks/marketplace#575 landed (the
legal-textsskill now names the platform as the one owner of the legal pages and keeps one pointer,docs/legal-sections.md): that document namesinfrastructure/legal/but does not say where the shared sections stand (infrastructure/legal/sections/<unit>/<page>.html), so a reader of the skill reaches them only through the composed pages. One line in the document cures it; it joins the next change ofdocs/legal-sections.mdunder this item.