Skip to content

P9: legal pages, person export and erasure, AI keys and expiry checks move from hal #173

Open
20 of 22 issues completed
Open

Description

@FlexOr2

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

The cut is the comment below; each slice gets its files and dependencies at its pull.

Invariants every slice keeps

  1. Nothing a maintained tool does is built here; each piece names what it was checked against.
  2. hal's copy of a tool leaves with hal (P11), and until then only one of the two serves a given address.
  3. The legal text is the operator's: sections move word for word, a changed sentence is his.
  4. Commands print names, counts and outcomes; a person's data or a key's value is never printed.
  5. 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-pull first, 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."

Activity

  1. added theissue type on Nov 8, 2026
  2. FlexOr2 commented on Nov 8, 2026

    @FlexOr2
    MemberAuthor

    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 --local builds /impressum, /datenschutz, /nutzungsbedingungen from legal/frame/ (skeleton + 6 block templates), hal's own sections legal/sections/{allgemein,keycloak,litellm,speech-runtime}/ and each app's legal/datenschutz.html (+ optional nutzungsbedingungen.html) read from ~/srv/<key>/.git at the shown release's commit (/home/felix-hummert/git/hal/src/hal/stack/legal_step.py:1-8, 272-290). A host-only app (no repo) is read at HEAD of its checkout (legal_step.py:293-307; hal#1148 uses this for skat). The pages are written to git-ignored legal/built/ and served by nginxinc/nginx-unprivileged on an internal network with a strict CSP and Cache-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 into legal/assets/appkit-<version>/, served at /_rechtliches/appkit-<version>/ (hal/src/hal/stack/appkit_step.py:1-10; hal/pyproject.toml:9 pins 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 has legal/datenschutz.html and nutzungsbedingungen.html; confirmed by ls), every app's footer links hallucinai.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 command mise run legal:build that regenerates committed apps/legal/built/*.html (checked by tests/generated/generated_files_match_regeneration.sh, the pattern of charts:render, AGENTS.md:13-16,180-181). The composer reads each app's two files at the tag of the ref in apps/<app>/*.yaml from GitHub (all four app repos are public: gh api repos/overnightworks/<app> --jq .private → false), the way scripts/verify_image_release.sh:402-419 already resolves tags. Route: Ingress host hallucinai.de with the five served paths; a tunnel line for the apex; hal's catalog gets tunnel: platform on the apex as hopin did (hal#1100 pattern, hal#1148 "Freeze").

    • Why not an image built by platform: Platform on k3s and Flux: from an empty PC to every app live, with hal archived #1 target picture says images are built in the app's CI; platform has no release path, and the images job would refuse an unattested ref (AGENTS.md:139-150).
    • Why not copy the app's legal files into the bump PR: works too (same PR as the ref, 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.
    • Why not keep building on hal until the cutover: every app that goes live on the platform before the cutover would still have its text composed by the operator's hal apply from ~/srv/<key> (hopin#493 "now": pages show no #hopin until his apply). P9a ends that.
    • The composer's code (hal's legal_pages.py 299, legal_section.py 421, ~150 lines of legal_step.py, tests 2.5k lines) moves once (invariant 3). Platform has no Python in mise.toml:6-15; the move adds a pinned python tool 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"; no LOGIN/PEOPLE verb in hal/src/hal/output/command.py:9-26. What exists: the app-side contract (docs/people.md: GET /api/admin/people/{sub}/export ZIP, 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> and people: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 through kcadm inside Keycloak's container as keycloak:move-group does (platform/mise.toml:604-640, "no secret reaches this shell").

    • Alternatives: a Job in-cluster (would need a standing credential, ruled out by the contract); per-app console walks (status quo, what hal#256 replaces); an admin UI (nothing a maintained tool covers here, and a UI for one operator is more than a command).
    • Tombstone register and replay: not recommended for the first cut; a restore within retention (4 weekly + 3 monthly, P7: Postgres, Keycloak, LiteLLM and backups run on the platform, tried on the PC with copies of hal's backups #116 line 22) can revive an erased person, and the erase is idempotent, so the operator re-runs it after a restore. Decision 4 below.

    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-app max_budget 10 $ and max_parallel_requests 5, the three routes and models from /v1/models, and links the customer rows of /services/litellm/admin members to the unlimited budget litellm-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's litellm_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 (its config.yaml carries 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.

    • Where: shipped commands over kubectl exec into the LiteLLM pod, which holds LITELLM_MASTER_KEY (PR LiteLLM runs in the layer litellm behind hal's front with its egress closed #163 mounts Secret litellm key master_key): litellm:key <app> generates a key from a declared table infrastructure/litellm/keys.yaml (alias = app, budget, parallel, models, routes) and writes the value once to a 0600 file the operator encrypts into platform-secrets (the keycloak:move-group file pattern, mise.toml:604-612); litellm:keys reconciles 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.
    • Alternatives: a Job like config-cli reading a ConfigMap (needs every app key's value in a second Secret to find keys by hash, two copies per key; rejected); hal's Python one-off moved into a Job with the LiteLLM image (430 lines, a runtime to pin; rejected until a second admin or a third app makes by-hand reconciling a chore).

    Expiry (hal expire). Daily 04:00 hal-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).

    • Where: a CronJob in namespace litellm at 04:00 running psql from the Postgres vendor image against Service postgres with the Secret postgres password and the statement as a ConfigMap expire.sql, printing five counts; Postgres's NetworkPolicy already admits any pod of the namespace (infrastructure/postgres/postgres.yaml:124-131). Checked against: LiteLLM's own maximum_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 litellm and the k3d cell.

    1d. The secret expiry checks

    Today. Less than the row suggests. hal secrets/daily hal check read ~/.secrets by stat, verify gh auth status, and print expiry unknown for every entry (hal/src/hal/workstation/secrets_view.py:752-754; hal/docs/home.md:285 "heute immer expiry unknown"). The only expiry data is expires_in_days = 366 on 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 daily hal-check timer (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-DD on the Secret manifest in platform-secrets. sops encrypts only data|stringData there (platform-secrets/.sops.yaml: encrypted_regex: ^(data|stringData)$), so the annotation is plaintext and readable in-cluster without decrypting anything. A shipped mise run check:secrets-expiry (the check:pod-logs pattern, 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 on kube_secret_annotations is the P5-era form), x509-certificate-exporter (certificates only, none here).

    • Alternative: ask GitHub live for the token's expiry (github-authentication-token-expiration header, hal/docs/architecture.md:427) from a pod mounting ghcr-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:check can 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.

    # Title Scope paths Ruled / source Cells Done when Risk Parallel
    P9a The legal pages are served from apps/legal on the platform from hal's frame and sections apps/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 thin build.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 line #1 guiding sentence; hal#110's ruled shape and blessed look (A2 picture); #116 ruled: section anchor stays agent-dienst until the cutover window, then ki-dienst with the apps in step k3d: a pod in cloudflared gets 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:build regenerates byte-equal pages (generated cell); a section that breaks the grammar fails legal:build naming file and line, never quoting text. PC: Flux Ready; the operator's hal fleet.yaml apex host gets tunnel: platform and the live page's body hash equals the committed built page (hal#110 check C1) Pages built from platform's own sections and hopin's legal/ at its ref are served live at the three addresses with hal's look; hal's legal container no longer serves them Material (public legal pages; a wrong build publishes a wrong notice): Codex gate —
    P9a does not split to 3 files: the validator and the assembler are one proof (a page is built only from validated sections, legal_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 from apps/; without them the page would lie by omission (hopin is live). Songmaker, presentator and skat get their blocks when their refs appear under apps/.
    P9b hal expire runs as the CronJob litellm-expire infrastructure/litellm/expire.yaml (CronJob 04:00, concurrencyPolicy: Forbid, restricted, psql from the postgres vendor image), infrastructure/litellm/expire.sql (via kustomization.yaml configMapGenerator), tests/litellm/expire_deletes_only_what_is_past_90_days.sh #1 guiding sentence; the privacy text's "spätestens 92 Tage" (hal/legal/sections/litellm/datenschutz.html); hal#945 k3d: seed one backdated and one fresh row per table through the Postgres pod, trigger the CronJob as a Job, assert counts 1/1/1/1/1 and the fresh rows stay, an admin-linked customer row stays, a second run deletes 0; the Job pod passes restricted and the image policy. PC: kubectl create job --from once after C3's load; counts only The CronJob exists in litellm, the cell holds, and after its first PC run the counts are reported Not material (deletes only what the text promises to delete; all-or-nothing statement) with P9c, P9e
    P9c New AI keys and budget changes through litellm:key and litellm:keys infrastructure/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.sh #1 guiding sentence "AI keys and budgets"; hal's api_limits.py defaults; agent-service.md "The key step" k3d: litellm:key demo against the mock LiteLLM makes one key with the declared fields and writes a 0600 file whose content is never printed; litellm:keys after a changed budget updates by alias and prints 1 changed; a second run prints 0. PC: operator runs litellm:key skat once; the file goes into platform-secrets by his hand skat's key exists in the restored LiteLLM with its declared budget, and a budget change is one pull request plus one command Material (a key is a secret; a wrong budget spends money): Codex gate with P9b, P9e
    P9d-1 people:export <sub> collects one person's export from every app with the operator's own token infrastructure/keycloak/realm.yaml (public client platform-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.sh hal/docs/people.md (the contract), hal#256 "Rulings that bind hal's tokens" 1-4, agent-runtime#672 line 18 A pattern (#160) k3d: a device-grant token for scope people-hopin carries aud = 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 another aud. PC: the operator signs in once and exports his own account The operator answers an access request for hopin and presentator with one command; the ZIPs land at 0600; nothing is printed but counts and paths Material (personal data leaves the apps): Codex gate after P7b landed (done) and hopin's TOKEN_CLIENTS/azp setting names platform-cli (app-side change, hopin's board)
    P9d-2 people:erase <sub> erases one person across the apps and Keycloak in hal's order mise.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-134 steps 1-5 minus the register (decision 4); hal#256's "withdraw on decline" rule k3d: against a throwaway realm user and hopin: lock → counts → erase → zeros → user gone; a 500 leftover ends after 3 tries naming kinds, never names; a decline before erasure changes nothing. PC: once on a throwaway user One command erases a person and reports per app kinds and counts Material (irreversible deletion): Codex gate; an operator yes before the destructive step inside the command after P9d-1
    P9e Secrets that expire carry their date, and check:secrets-expiry names those within 30 days mise.toml (check:secrets-expiry), scripts/check_secrets_expiry.sh, tests/secrets/expiry_check_names_each_breach.sh (fake kubectl); platform-secrets: the annotation on ghcr-pull and secrets:check requiring it on token kinds (its own PR there) #1 guiding sentence "the secret checks"; platform-secrets/.sops.yaml (metadata plaintext) CI: against a fake kubectl, passes a date 31 days out, fails naming a Secret at 29 days and one past, fails on an unparseable date, passes with no annotated Secret. PC: mise run check:secrets-expiry once Every Secret whose value expires carries a date, and the check fails naming it 30 days ahead Not material with P9b, P9c
    P9f (later) The checks run on a schedule and alert CronJobs for P9e; P5's alert rules for failed Jobs in litellm and the check P5 k3d A failed expire or an expiring secret reaches Alertmanager Not material after P5

    presentator'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:

    # Title Scope paths Ruled / source Cells Done when Risk Parallel
    PW0 RuntimeClass no-network (#68, exists) host/k3s/ (containerd handler config, dummy CNI pinned), infrastructure/runtimeclass/, tests/k3s/ presentator#865 card A contract k3d: a pod in the class has only 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) A pod in the class proves no route Material (cluster runtime change) first
    PW1 presentator's web and worker run in k3d from apps/presentator apps/presentator/presentator.yaml (web Deployment Recreate, uid 1001, Host: probe to /health, 3 PVCs database/builds/mirrors + RWO build-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/litellm once 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, Role delete on the one pod by resourceNames), apps/presentator/kustomization.yaml, clusters/pc/apps.yaml, infrastructure/namespaces/namespaces.yaml (baseline enforce, restricted warn/audit) presentator#865 "What platform provides"; #43 comment 18:48 deviation request → operator card (decision 5); docs/app.md checklist 6 k3d: the web is Ready against the worker, hello-deck builds with no network, the worker pod refuses in the default class (no-route), the web passes restricted dry-run, the worker only baseline; the web's SA can delete only its worker pod presentator's released image runs in k3d in the chosen shape Material (security boundary, PSS deviation) after PW0 and presentator#865 S3 (the kubernetes seal)
    PW1 does not split near 100 lines: the web refuses to start without the worker within 70 s (#43 comment 14:09, "Probe"), so web-only is not honest; ~250 YAML lines.
    PW2 presentator's two hosts route through Traefik and the tunnel, and Keycloak's back-channel logout reaches it apps/presentator/presentator.yaml (Ingress for presentator.hallucinai.de and presentator-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-logout denied at the edge as hal's deny_public), CoreDNS rewrite if U8's fallback is needed hopin's #117 pattern; #116 U8 k3d: 200 on both hosts from cloudflared, no answer from another namespace; Keycloak's pod reaches presentator:8000. PC: hal fleet.yaml gets tunnel: platform on both hosts (hal frozen takes this rename) Both hosts are served by the platform; a Keycloak logout ends a presentator session Not material after PW1
    PW3 presentator's data moves once and is backed up on the platform's path mise.toml (presentator:load-database <file>: copies hal's SQLite online copy into the database PVC with the web at 0, like db:load-dump), apps/presentator/presentator.yaml (k8up annotations: backupcommand = python sqlite3.Connection.backup to stdout; builds declared as cache or truth per decision 6), tests/presentator/restored_database_serves_the_deck_list.sh #165 ruled "ein Weg für alle"; hal#280 "presentator's data" (database truth, builds derived, mirrors cache except known_hosts); backup/apps.toml:40-47 k3d: load a fixture SQLite, the web serves /health and the deck list; a Backup yields a snapshot with the SQLite stream. PC: operator's hal restore presentator --target, then presentator:load-database; builds volume copied once or rebuilt (decision 6) presentator runs live on the platform on its moved data with a backup; hal's compose stack stops on his word; presentator#865 S8 opens Material (data move) after PW2, P7d (k8up) and #165's file part if builds is truth

    3. Dependencies and what starts today

    4. Decisions only the operator can make (German, plain)

    1. Rechtsseiten: Wann ziehen die drei Seiten um? Empfehlung A. A: Sobald die Plattform sie baut, zeigen die Adressen auf die Plattform, noch vor der großen Umstellung, so wie bei HopIn. B: Erst bei der großen Umstellung, bis dahin baut sie der alte Stand weiter (dann fehlt HopIns Teil, bis du den alten Stand einmal anwendest). C: Beides parallel, zwei Stände derselben Seite (abgeraten: zwei Quellen).
    2. Wortlaut: Der Text bleibt deiner. Die Plattform übernimmt die Abschnitte wortgleich; nur die Umbenennung „Agent-Dienst“ zu „KI-Dienst“ kommt im Umstellungsfenster, wie bereits entschieden. Frage: Darf der allgemeine Teil den Satz zu den Protokollen bekommen („höchstens 30 Tage“), weil das auf der Plattform jetzt stimmt? Empfehlung ja.
    3. KI-Schlüssel neuer Admins: Wer in die Admin-Gruppe kommt, bekommt sein unbegrenztes KI-Budget heute automatisch beim Anwenden. Empfehlung A. A: Künftig ein Befehl von dir pro neuem Admin (heute bist du der einzige). B: Automatisch durch einen Lauf im Cluster, der die Gruppe liest (mehr Code, ein weiterer Leser des Keycloak-Zugangs). C: Gar nicht; Admins zahlen aus dem normalen Budget.
    4. Löschen einer Person und die Sicherungen: Eine Wiederherstellung aus einer Sicherung (bis etwa drei Monate alt) kann eine gelöschte Person zurückbringen. Empfehlung A. A: Kein eigenes Register; nach jeder Wiederherstellung führst du die Löschung noch einmal aus, der Befehl ist wiederholbar, und die Datenschutzerklärung nennt die drei Monate (tut sie für Keycloak schon). B: Ein Register gelöschter Personen, das nach jeder Wiederherstellung automatisch nachzieht (deutlich mehr Code, Teil der Wiederherstellung). C: Sicherungen nur 7 Tage behalten (hal hatte das einmal als Regel interrupt_load.sh's INT on the TOC-heavy dump sometimes sees no blocked pg_restore within 300 seconds #157; weniger Schutz bei spätem Fehler).
    5. presentator: Abweichung für den Bauarbeiter. Der Bauarbeiter-Pod braucht Root mit drei Rechten, um fremden Deck-Code in getrennten Benutzern laufen zu lassen; er fällt damit aus der strengsten Pod-Regel. Empfehlung A. A: Nur dieser eine Pod bekommt die mittlere Stufe, der Web-Teil bleibt streng; die Plattform prüft, dass sonst nichts in dem Namensraum lockerer läuft. B: Alles streng; Deck-Code läuft dann unter demselben Benutzer wie die Steuerung (schwächere Abdichtung). C: presentator bleibt vorerst auf dem alten Stand.
    6. presentator: Welche Daten ziehen um? Empfehlung A. A: Die Datenbank (Konten, Quellen, Decks) zieht um und wird gesichert; die gebauten Vorträge werden beim nächsten Push neu gebaut, alte ungepushte Decks gehen verloren (du hast gesagt, es sind Prototyp-Daten). B: Datenbank und gebaute Vorträge ziehen um und beide werden gesichert (wartet auf den Datei-Teil der Sicherung, der erst mit songmaker kommt). C: Frischer Start mit leerer Datenbank.
    7. Ablaufdaten: Empfehlung ja. Jeder Zugangsschlüssel, der bei seinem Anbieter abläuft (heute der zum Bilder-Laden von GitHub), bekommt in der Geheimnis-Ablage sein Ablaufdatum daneben geschrieben, und eine Prüfung meldet 30 Tage vorher. Alternative: die Prüfung fragt den Anbieter live (nur GitHub möglich).

    5. Predecessors and exits on hal

    • legal unit, legal/ tree, the legal and appkit steps, hal legal: leave with hal's archive after P9a's live go; until then hal's hal apply keeps writing legal/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.
    • The key step of hal apply, litellm-keys one-off and ~/.secrets/litellm/apps/*: leave with hal; their values move into platform-secrets at the cutover.
    • hal secrets/hal check secret rows: leave with hal; nothing on the platform replaces the ~/.secrets tree checks, since the tree leaves too.
    • hal#256 and hal#680: close on hal pointing at P9d; docs/people.md moves to platform's docs/ (the contract is cross-repo and must survive hal).
    • hal's backup/apps.toml [app.presentator] and presentator's compose.yaml: presentator#865 S8 after PW3.

    6. Risks and unknowns

    • Fonts in a ConfigMap: appkit 0.15.0's woff2 files' total size unconfirmed; ConfigMap limit 1 MiB. Fallback: an emptyDir filled by an initContainer from the wheel is rejected (network at start); a second ConfigMap per font works.
    • Python in platform: no runtime tool today (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.
    • generated cell with network: legal:build fetches app files at tags; charts:render already 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.
    • STABLE_IDS and the rename: apps link #agent-dienst today (presentator legal/datenschutz.html:11); the composer's link check must allow the old id until the window PR renames section and apps together.
    • People routes in songmaker and skat: not found by grep; P9d covers only the apps that answer.
    • Device grant phishing (hal#256): bounded, since a token only reaches /api/admin/people for members of /apps/<app>/admin; still a realm-wide client. Head decision, flagged.
    • ghcr-pull kind: whether it is a fine-grained PAT with an expiry or a classic token is unconfirmed; P9e's annotation works either way.
    • Expire CronJob vs k8up window: both at Sunday 03:00/04:00; the delete runs inside one transaction and the dump is transactional, so a race only decides which side of the delete the dump sees.
    • presentator's 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.
    • PW0's host step restarts k3s, not Docker; Invariant 1 still asks for one notice before the first host step.
    • Vendor entries: P9a adds nginx-unprivileged; P9b reuses postgres; none for P9c-e.

    Critical Files for Implementation

    • /home/felix-hummert/git/hal/src/hal/stack/legal_pages.py and /home/felix-hummert/git/hal/src/hal/stack/legal_section.py (the composer that moves once into platform's src/legal/)
    • /home/felix-hummert/git/hal/src/hal/litellm_expiry.py (the statement for infrastructure/litellm/expire.sql)
    • /home/felix-hummert/git/platform/mise.toml (keycloak:move-group at 604-640 is the pattern for every shipped command of P9c, P9d, P9e, PW3)
    • /home/felix-hummert/git/platform/infrastructure/keycloak/realm.yaml (the platform-cli client and per-app audience scopes for P9d)
    • /home/felix-hummert/git/platform/apps/hopin/hopin.yaml (the app manifest pattern for apps/legal and apps/presentator)
  3. changed the issue type fromtoon Nov 8, 2026
  4. FlexOr2 commented on Nov 8, 2026

    @FlexOr2
    MemberAuthor

    Follow-up from #175's review, cosmetic: the step's name in checks is very long; shorten it when the file is next touched.

  5. FlexOr2 commented on Nov 8, 2026

    @FlexOr2
    MemberAuthor

    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.

    • B1 (P9a-4): apps/ holds hopin, skat and speech-runtime, while hal's fleet.yaml:15-24 serves songmaker and presentator, and presentator links datenschutz#presentator. Going live before the cutover needs a declared source list (key to tag or sha) that a check ties to the refs under apps/.
    • B2 (P9a-3): Stand comes from git 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.
    • B3 (P9a-5): the ruled 30-day sentence needs its exact wording and place; the German is his.
    • B4 (P9a-1): apps/legal breaks docs/app.md (a vendor nginx is no release-path image). It goes to infrastructure/legal/.
    • B5 (P9a-2): a root pyproject.toml would make the shell tests/ a judged tree (test-layout, quality v0.10.0 docs/rules.md:282-290). It needs a nested project with its own tests, a pytest and coverage step, the coverage-artifact input and a Sonar scope. bootstrap_pc.sh --only tools would install the python tool on the PC too.
    • B7 (P9a-2): hal legal needs a successor, legal:check <key> <dir>, the section contract moved to platform's docs, and the legal-texts skill's pointer updated. presentator/quality/config.toml:60 names hal too.
    • B8 (P9a-3): a cell shows the moved sections equal hal's at a named commit, and that the 30-day sentence is the only delta.
    • F1: do not port fingerprint_pages, BlockFingerprint, PageFingerprint or LegalPlan; git diff built/ replaces them.
    • F2: appkit's fonts are 125,340 bytes, under the ConfigMap limit. Still unnamed: how the wheel's files reach platform, and that the frame hardcodes appkit-0.15.0 while appkit is at 0.16.0.
    • F3: name the owner of zone, and what replaces _catalog_keys and the two-owner refusal.

    Nothing was run for this review; gate behaviour comes from quality's docs and source at v0.10.0.

  6. FlexOr2 commented on Nov 8, 2026

    @FlexOr2
    MemberAuthor

    Cut of P9a-3 (read-only planner, 08.11.2026), kept here because P9a-3b and P9a-4 take it at their pull. H is hal, P is 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

    • Reads infrastructure/legal/frame/ (3 page skeletons + 6 blocks/) and infrastructure/legal/sections/<unit>/<page>.html, builds all three pages with the ported assembler (checks ids and links), writes ONLY infrastructure/legal/built/impressum.html (the one page the layer serves, kustomization.yaml configMapGenerator). datenschutz and nutzungsbedingungen stay in memory: without app blocks they would be wrong pages.
    • Impressum: the frame has no $field (H legal/frame/impressum.html), so build_pages with no blocks returns the frame bytes. P built/impressum.html is already byte-identical to H's current frame (cmp: IDENT). H's own legal/built/ is stale (appkit-0.8.0, tabs/footer), is git-ignored (H .gitignore:16): never the reference. Keep appkit-0.15.0 in the frame; the bump to 0.16.0 changes bytes and is P9a-4 (F2).
    • Moves from H at a5c23427 (last commit touching them: legal/frame 171c02b4, sections a5c23427): 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.
    • P9a-4: app blocks (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/.
    • Found: P section.py lacks parse_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:

    • A "Assembler and Impressum" (thinnest end-to-end proof). Files: src/legal/src/legal/pages.py, build.py, mise.toml task legal:build, frame files moved, tests (test_pages.py, test_build.py), tests/generated/generated_files_match_regeneration.sh (+mise run legal:build; it already covers infrastructure/ and git-ignore, ci.yml:161), AGENTS.md. Done when: mise run legal:build leaves git diff built/ empty and CI's regeneration step passes with it. Production lines well past 100 (verbatim move; say so in dispatch).
    • B "Sections and Stand" (after A). Files: legal/build.py (reads sections, stand), parse_general into section.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

    • Ported from H legal_pages.py (data and logic unchanged): 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).
    • NOT ported (F1): 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 0644 fchmod is not needed (ConfigMap).
    • Not in A or B: check_app of H (:234): P check.py has 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 in infrastructure/legal/legal.yaml (already so for legal:check, mise.toml:415-416; docs/legal-sections.md zone line). legal:build reads it by the same yq line and passes it as an argument, no catalog. The one-line yq repeats; judged shorter than a shared helper.
    • _catalog_keys replacement: none needed. A directory under sections/ is a unit only if it is allgemein or a key of SERVICE_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 is allgemein/in SERVICE_ANCHORS or has a sections/<key>/. Open for P9a-4: how an app file's Stand is declared (B2 applies to apps; H uses git %cs of the file at the tag).

    3b. Stand (B2), part B

    • H prints Stand: <_german(max block date)> per page (H frame datenschutz.html:19; _page stand=""). 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 shows 27. Oktober 2026 (stale).
    • Declaration: infrastructure/legal/stand.toml (outside sections/, so diff -r against H stays clean), stdlib tomllib, one table per section path: date = 2026-11-05 (TOML date) and sha256 of 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 %cs at 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: datenschutz 8. November 2026, terms 5. November 2026 (app blocks may raise it in P9a-4).

    4. Tests and cells

    • Port: all of H tests/test_stack_legal_pages.py (334 lines, hal-type-free logic; its L14 "imprint is the frame" and Stand tests included, Stand tests fed by Block.date), the parse_general tests, and new test_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).
    • Coverage: src/legal/pyproject.toml fail_under 100 branch, source already src/legal/src/legal; every new branch needs a test, main of build.py follows check.py (__main__ is excluded already). The section.py exclude (return min(text.count) is untouched.
    • B8 cell (B): tests/legal/sections_equal_hals_at_commit.sh in checks next to the hopin cell (ci.yml:66-67): hal is PUBLIC (gh repo view: isPrivate false); git init + git fetch --depth 1 <url> a5c2342722266240a363a2c4e5016ba889ce3c1a into a temp dir, then diff -r of H legal/sections with infrastructure/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

    • AGENTS.md:453-455 ("built/impressum.html is hal's legal/frame/impressum.html byte for byte"), :246 (cluster job regenerates "gotk-components.yaml, mise.lock and the renders": add the page), :569-571 (section.py "without parse_general"; add pages.py, build.py, the new cell), the legal: command entries (:64-71) get legal:build, and its Layout line for stand.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.sh pins the built file's checksum: stays valid only because bytes do not change; that is the proof.
    • src/legal/pyproject.toml comments (exclude lines): unchanged. check.py usage text usage: legal.check <key> <dir> <zone> and mise.toml:408-417: unchanged. New: task name legal:build, its usage/refusal strings, legal: refusal prefix.
    • infrastructure/legal/kustomization.yaml files: built/impressum.html: unchanged. .gitignore of 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 2026 first in P9a-5. Nothing built here goes live: only built/impressum.html is served and it does not change; datenschutz/nutzung reach live in P9a-5.

  7. FlexOr2 commented on Nov 8, 2026

    @FlexOr2
    MemberAuthor

    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:

    1. The blessed look. The operator blessed the S4 picture on 27.10.2026 („ein ja passt alles“: light with automatic dark mode, the back link „‹ hallucinai.de“, each app its own colour as its head names it; Microsoft's table of contents with „Allgemein“ and one block per app, the tabs stay). The mockup is https://claude.ai/artifact/LTphuHxS9T5dD8GSZzUKda and the frozen picture is docs/design/legal-pages.html in 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.
    2. The strict rule, ruled 26.10.2026. An app without a valid privacy section is not made public: the deployment refuses before anything changes while a public app lacks one (hal#110, line L2). Ruled exception (27.10.2026, QS2): HopIn stays public until its approved text ships through hopin#46.

    Neither has to wait for the cutover; both are yours to schedule in P9.

  8. 16 remaining items

  9. FlexOr2 commented on Nov 9, 2026

    @FlexOr2
    MemberAuthor

    mise run litellm:admin felix ran 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-20261109 in keycloak, litellm, skat and songmaker, all Completed 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 unchanged and exited 0. felix was already on the admin budget, in the group and on his customer row, so nothing was written.

  10. FlexOr2 commented on Nov 9, 2026

    @FlexOr2
    MemberAuthor

    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).

  11. FlexOr2 commented on Nov 9, 2026

    @FlexOr2
    MemberAuthor

    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 on HostSNI (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.

  12. FlexOr2 commented on Nov 9, 2026

    @FlexOr2
    MemberAuthor

    Three follow-ups from #324's final gate (#289), for P9a-5b:

    • docs/personal-data.md:16-19 and :26-27 list "The promise:" without the new allgemein#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 from ci.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:31 says "Audit-Log: wird derzeit ohne feste Frist gespeichert", while allgemein/datenschutz.html:21 says 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.
  13. FlexOr2 commented on Nov 9, 2026

    @FlexOr2
    MemberAuthor

    Follow-ups of #290's gate on #333, for this item's legal-pages part:

  14. FlexOr2 commented on Nov 9, 2026

    @FlexOr2
    MemberAuthor

    From marketplace's head, 09.11.2026, after overnightworks/marketplace#575 landed (the legal-texts skill now names the platform as the one owner of the legal pages and keeps one pointer, docs/legal-sections.md): that document names infrastructure/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 of docs/legal-sections.md under this item.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions