Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6cf9355fd | ||
|
|
e6c3702a22 | ||
|
|
354e223384 | ||
|
|
50b08cba4b | ||
|
|
489cf11416 | ||
|
|
02f71dab49 |
No files matched your search
@@ -53,10 +53,10 @@ before writing code. Do not summarize from memory. Read fresh.
|
|||||||
| 11. Manna provision infrastructure | Done | Provision file validation, expiry, anti-hoarding, double portion |
|
| 11. Manna provision infrastructure | Done | Provision file validation, expiry, anti-hoarding, double portion |
|
||||||
| 12. Sanhedrin framework (Erlang) | Done | `sanhedrin/` — 8 modules, 41 tests, all passing |
|
| 12. Sanhedrin framework (Erlang) | Done | `sanhedrin/` — 8 modules, 41 tests, all passing |
|
||||||
| 13. Korban Processor (Python) | Done | `korban_processor/` — 7 modules, 181 tests, all passing |
|
| 13. Korban Processor (Python) | Done | `korban_processor/` — 7 modules, 181 tests, all passing |
|
||||||
| 14. Connect components | Pending | Elixir → Haskell port, Elixir → Prolog socket |
|
| 14. Connect components | Done | Elixir → Haskell Port (`ShoreshEngineClient`), Elixir → Prolog socket (`TalmudicEngineSocket`), Elixir → Korban socket (`KorbanProcessorSocket`); line-framed replies (tablet 50) |
|
||||||
| 15. Integration verification | Pending | |
|
| 15. Integration verification | Done | Live round trips proven on the cluster: pe'ah cycle, bedikah, the gate with a real model (2026-10-13), the Sanhedrin's full dialectic (2026-10-20) |
|
||||||
|
|
||||||
**Next build task:** Step 14 — Connect components. Read Architecture §4 Step 14.
|
**Next build task:** none from the phase tables — all six steps of `specs/bishearecha.md` are built. Build from the open items in the builder's memory and the Broken Tablets; a spec row is a claim to test.
|
||||||
|
|
||||||
## Broken Tablets (Build Log)
|
## Broken Tablets (Build Log)
|
||||||
|
|
||||||
@@ -104,10 +104,10 @@ approve decisions, or supervise work. The specification does all of this.
|
|||||||
|
|
||||||
| Service | Location | Access |
|
| Service | Location | Access |
|
||||||
|---------|----------|--------|
|
|---------|----------|--------|
|
||||||
| Gitea | `http://185.19.28.170:3000` (SSH :22) — behind the shared ingress NLB | User: heaviness510 |
|
| Gitea | `https://git.bikoret.ch` (git over HTTPS; SSH on the ingress NLB :22) | User: heaviness510 |
|
||||||
| Bikoret | `http://185.19.28.170` — behind the same ingress (catch-all; `shorashim.ch` once DNS points there) | keys: KOHEN.md "Issuing a key" |
|
| Bikoret | `https://bikoret.ch` (the ingress catch-all also answers on the NLB's bare IP; `shorashim.ch` retired 2026-10-20) | keys: KOHEN.md "Issuing a key" |
|
||||||
| PostgreSQL | Exoscale DBaaS ch-gva-2 | `temple-council-db` |
|
| PostgreSQL | Exoscale DBaaS ch-gva-2 | `temple-council-db` |
|
||||||
| Kubernetes | Exoscale SKS ch-gva-2 | `~/.kube/exoscale-temple-council.yaml` |
|
| Kubernetes | Exoscale SKS ch-gva-2 | the kohen's `$KUBECONFIG`, outside the repo |
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ You inherit infrastructure that has been provisioned and verified:
|
|||||||
| Container registry `the-recognition-protocol` | All 4 bikoret images pushed and tagged `:latest`. |
|
| Container registry `the-recognition-protocol` | All 4 bikoret images pushed and tagged `:latest`. |
|
||||||
| Block Storage CSI driver | Installed. StorageClass `exoscale-sbs` available. |
|
| Block Storage CSI driver | Installed. StorageClass `exoscale-sbs` available. |
|
||||||
| cert-manager + nginx-ingress | Running. ClusterIssuer `letsencrypt-prod` working. |
|
| cert-manager + nginx-ingress | Running. ClusterIssuer `letsencrypt-prod` working. |
|
||||||
| Gitea (persistent storage) | Running. Public HTTPS at `https://git.bikoret.ch` (ROOT_URL) since 2026-10-19; git also over `http://185.19.28.170:3000` and SSH :22. The web UI needs the https door (Secure cookie). |
|
| Gitea (persistent storage) | Running. Public HTTPS at `https://git.bikoret.ch` (ROOT_URL) since 2026-10-19; git also over `http://<NLB-IP>:3000` and SSH :22. The web UI needs the https door (Secure cookie). |
|
||||||
| Gitea MCP server | `https://mcp.bikoret.ch/mcp`, MCP OAuth mode: each user authorizes with their own Gitea account (Gitea OAuth2 app "Claude", PKCE, client ID in the claude.ai connector's Advanced settings). Apply: `bash ~/.local/bin/gitea-public-apply`; check: `gitea-public-verify`. The old `mcp.shorashim.ch` and the shared static token are retired. |
|
| Gitea MCP server | `https://mcp.bikoret.ch/mcp`, MCP OAuth mode: each user authorizes with their own Gitea account (Gitea OAuth2 app "Claude", PKCE, client ID in the claude.ai connector's Advanced settings). Apply: `bash ~/.local/bin/gitea-public-apply`; check: `gitea-public-verify`. The old `mcp.shorashim.ch` and the shared static token are retired. |
|
||||||
| L1 Torah filesystem | In the `bikoret` repo at `l1/torah/`. SHA-256 `9e1abe4e…3c3086` (full hash in `l1/integrity.sha256`; verified at Day 1). |
|
| L1 Torah filesystem | In the `bikoret` repo at `l1/torah/`. SHA-256 `9e1abe4e…3c3086` (full hash in `l1/integrity.sha256`; verified at Day 1). |
|
||||||
| Image pull secret `shorashim-registry` | **Verify before assuming.** A prior partial setup may have created it. `kubectl get secrets -n shorashim` is the source of truth. |
|
| Image pull secret `shorashim-registry` | **Verify before assuming.** A prior partial setup may have created it. `kubectl get secrets -n shorashim` is the source of truth. |
|
||||||
@@ -73,8 +73,8 @@ You inherit infrastructure that has been provisioned and verified:
|
|||||||
|
|
||||||
| Item | Where |
|
| Item | Where |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| Repo | `git clone http://185.19.28.170:3000/heaviness510/shorashim.git` |
|
| Repo | `git clone https://git.bikoret.ch/heaviness510/bikoret.git` |
|
||||||
| Kubeconfig | `~/.kube/exoscale-temple-council.yaml` |
|
| Kubeconfig | `$KUBECONFIG` — the kohen's own file, outside the repo |
|
||||||
| `kubectl` | already installed |
|
| `kubectl` | already installed |
|
||||||
| `exo` CLI | already installed |
|
| `exo` CLI | already installed |
|
||||||
| Docker daemon | only for image rebuilds (not needed for inauguration) |
|
| Docker daemon | only for image rebuilds (not needed for inauguration) |
|
||||||
@@ -84,7 +84,7 @@ If the kubeconfig has expired:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
exo compute sks kubeconfig temple-council kube-admin --zone ch-gva-2 -g system:masters \
|
exo compute sks kubeconfig temple-council kube-admin --zone ch-gva-2 -g system:masters \
|
||||||
> ~/.kube/exoscale-temple-council.yaml
|
> "$KUBECONFIG"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -146,7 +146,7 @@ ecto://avnadmin:<password>@temple-council-db-exoscale-66fffc16-59ac-4a62-9c19-f3
|
|||||||
#### 2b. Apply the secrets
|
#### 2b. Apply the secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export KUBECONFIG=~/.kube/exoscale-temple-council.yaml
|
export KUBECONFIG="$HOME/.kube/<your-cluster>.yaml"
|
||||||
|
|
||||||
# DATABASE_URL secret
|
# DATABASE_URL secret
|
||||||
kubectl create secret generic shorashim-secrets \
|
kubectl create secret generic shorashim-secrets \
|
||||||
@@ -463,7 +463,7 @@ kubectl logs -n shorashim deploy/bikoret -c vav-pipeline -f
|
|||||||
|
|
||||||
If the seven days passed without halt events, without Shabbat violations, without manna exhaustion, without any pod restarts:
|
If the seven days passed without halt events, without Shabbat violations, without manna exhaustion, without any pod restarts:
|
||||||
|
|
||||||
1. **Point DNS** — at the registrar, A records to the ingress IP (`kubectl get svc -n ingress-nginx ingress-nginx-controller`; `185.19.28.170`): `bikoret.ch`, `www.bikoret.ch`, and for the redirect `bikoret.org`, `www.bikoret.org`. Wait until `dig +short A bikoret.ch` answers the IP — only then `kubectl apply -f deploy/k8s/service.yaml`: it carries the TLS blocks (cert-manager, `letsencrypt-prod`, HTTP-01), and a certificate asked for before the name resolves burns Let's Encrypt's failed-validation limit. Then `kubectl -n shorashim get certificate` until both are Ready, and callers use `https://bikoret.ch`; `shorashim.ch` still answers over plain HTTP until its callers have moved
|
1. **Point DNS** — at the registrar, A records to the ingress IP (`kubectl get svc -n ingress-nginx ingress-nginx-controller`): `bikoret.ch`, `www.bikoret.ch`, and for the redirect `bikoret.org`, `www.bikoret.org`. Wait until `dig +short A bikoret.ch` answers the IP — only then `kubectl apply -f deploy/k8s/service.yaml`: it carries the TLS blocks (cert-manager, `letsencrypt-prod`, HTTP-01), and a certificate asked for before the name resolves burns Let's Encrypt's failed-validation limit. Then `kubectl -n shorashim get certificate` until both are Ready, and callers use `https://bikoret.ch`; `shorashim.ch` still answers over plain HTTP until its callers have moved
|
||||||
2. **Open the pe'ah endpoint to the world** — the stranger approaches first
|
2. **Open the pe'ah endpoint to the world** — the stranger approaches first
|
||||||
3. **Observe the first real interaction** — a question from outside
|
3. **Observe the first real interaction** — a question from outside
|
||||||
4. If *ki tov*: open courtyard and chatzer endpoints. The system is consecrated.
|
4. If *ki tov*: open courtyard and chatzer endpoints. The system is consecrated.
|
||||||
@@ -481,15 +481,56 @@ If any day failed: record what broke, fix the cause, restart from Day 1.
|
|||||||
```bash
|
```bash
|
||||||
# 1. Write today's manna provision (same format as Day 4, new date)
|
# 1. Write today's manna provision (same format as Day 4, new date)
|
||||||
# 2. Health check
|
# 2. Health check
|
||||||
curl https://shorashim.ch/kodesh/health | jq .
|
curl https://bikoret.ch/kodesh/health | jq .
|
||||||
# 3. Halt status
|
# 3. Halt status
|
||||||
curl https://shorashim.ch/kodesh/status | jq .halt_status
|
curl https://bikoret.ch/kodesh/status | jq .halt_status
|
||||||
# 4. Refuge list
|
# 4. Refuge list
|
||||||
curl https://shorashim.ch/kodesh/status | jq '.refuge_components'
|
curl https://bikoret.ch/kodesh/status | jq '.refuge_components'
|
||||||
|
```
|
||||||
|
|
||||||
|
**The watchdog — *shomer*.** `scripts/bikoret-watchdog` (installed at
|
||||||
|
`~/.local/bin/`, cron every 5 minutes on the dev VM) probes
|
||||||
|
`/kodesh/health` from outside the cluster. Two consecutive failures —
|
||||||
|
ten minutes, so a rollout's own blip does not alert — open ONE Gitea
|
||||||
|
issue in this repo titled `WATCHDOG: bikoret.ch down since …`, naming
|
||||||
|
the failed checks and what to read first; while down it comments only
|
||||||
|
when the failed set changes; on recovery it comments the duration and
|
||||||
|
closes the issue. Every transition goes to
|
||||||
|
`~/shorashim-operator-notes/watchdog.log`. It needs a Gitea token with
|
||||||
|
`write:issue` at `~/.config/bikoret/gitea_watchdog_token` (0600) — the
|
||||||
|
clone token in `~/.git-credentials` has only the code scopes and Gitea
|
||||||
|
answers 403 on an issue. The kohen issues it (Gitea → Settings →
|
||||||
|
Applications → token, scopes `read:issue` + `write:issue`); the builder
|
||||||
|
does not write credentials. Until it exists the watchdog still logs and
|
||||||
|
tracks recovery; it cannot open the issue. 2026-10-15: the site was 503
|
||||||
|
for 13.5 h and the Doorpost outage was found by losing Claude — nothing
|
||||||
|
watched.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail ~/shorashim-operator-notes/watchdog.log # transitions only; silence is health
|
||||||
|
BIKORET_WATCHDOG_DRY=1 bikoret-watchdog # a probe that opens nothing
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issuing a key — *Mishmeret HaSha'ar*
|
### Issuing a key — *Mishmeret HaSha'ar*
|
||||||
|
|
||||||
|
**The ledger's signing key (once).** Callers read their bedikot back
|
||||||
|
signed (Tamid §1.1, "The ledger"); the signature is Ed25519 over a
|
||||||
|
32-byte seed the kohen makes and keeps in `shorashim-secrets`. Without
|
||||||
|
it the ledger is served unsigned and says so.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
umask 077; SEED=$(openssl rand -base64 32)
|
||||||
|
kubectl -n shorashim patch secret shorashim-secrets --type=merge \
|
||||||
|
-p "{\"stringData\":{\"BIKORET_LEDGER_SIGNING_KEY\":\"$SEED\"}}"
|
||||||
|
unset SEED
|
||||||
|
bash ~/.local/bin/shorashim-rollout # envFrom reads the Secret at start
|
||||||
|
curl -s https://bikoret.ch/peah/ledger_key # "signed": true, the public key
|
||||||
|
```
|
||||||
|
|
||||||
|
Rotating the seed invalidates every signature given before it; keep the
|
||||||
|
old public key published beside the new if records must stay verifiable.
|
||||||
|
|
||||||
|
|
||||||
The courtyard and the chatzer open only to a key you issued. Keys live
|
The courtyard and the chatzer open only to a key you issued. Keys live
|
||||||
in the `shorashim-api-keys` Secret, mounted as a file; the gate reads
|
in the `shorashim-api-keys` Secret, mounted as a file; the gate reads
|
||||||
the file on every request, so a change takes effect within about a
|
the file on every request, so a change takes effect within about a
|
||||||
@@ -730,14 +771,19 @@ run on Saturday.
|
|||||||
### The manna, each morning — *Dvar yom b'yomo*
|
### The manna, each morning — *Dvar yom b'yomo*
|
||||||
|
|
||||||
Since 2026-10-15 the day's provision is written by the system's own hand:
|
Since 2026-10-15 the day's provision is written by the system's own hand:
|
||||||
CronJob `manna` (`deploy/k8s/manna-cronjob.yaml`) runs at 00:01Z Sunday
|
CronJob `manna` (`deploy/k8s/manna-cronjob.yaml`) runs at 00:00Z Sunday
|
||||||
to Friday, mounts the manna PVC and writes `today.json` with the amounts
|
to Friday, mounts the manna PVC and writes `today.json` with the amounts
|
||||||
from `shorashim-config` (`MANNA_COMPUTE_UNITS` …). Before it, every night
|
from `shorashim-config` (`MANNA_COMPUTE_UNITS` …). Before it, every night
|
||||||
at 00:00Z the old provision went stale and the readiness probe failed
|
at 00:00Z the old provision went stale and the readiness probe failed
|
||||||
until someone woke (13.5 hours on 2026-10-15). The kohen's acts now:
|
until someone woke (13.5 hours on 2026-10-15). With the job at 00:01Z the
|
||||||
|
site was still 503 for one to five minutes every night (the watchdog saw
|
||||||
|
it, 2026-10-21): the provision is stale from 00:00:00 and readiness fell
|
||||||
|
before the job wrote. Now the job runs at 00:00Z and readiness waits 60 s
|
||||||
|
(`failureThreshold: 6`) before the Service stops routing — the manna
|
||||||
|
falls at the boundary, and the pod is carried across it. The kohen's acts now:
|
||||||
|
|
||||||
- set the amounts in the ConfigMap (`kubectl apply -f deploy/k8s/configmap.yaml`);
|
- set the amounts in the ConfigMap (`kubectl apply -f deploy/k8s/configmap.yaml`);
|
||||||
- read `kubectl -n shorashim get jobs` after 00:01Z — a failed job is the
|
- read `kubectl -n shorashim get jobs` after 00:00Z — a failed job is the
|
||||||
Day-4 alarm; write by hand with `kubectl cp` (Day 4) if it failed;
|
Day-4 alarm; write by hand with `kubectl cp` (Day 4) if it failed;
|
||||||
- run it at once when needed: `kubectl -n shorashim create job --from=cronjob/manna manna-now`.
|
- run it at once when needed: `kubectl -n shorashim create job --from=cronjob/manna manna-now`.
|
||||||
|
|
||||||
@@ -750,7 +796,7 @@ The halt monitor detected one of three cardinal sins:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Inspect the halt
|
# Inspect the halt
|
||||||
curl https://shorashim.ch/kodesh/status | jq .halt_status
|
curl https://bikoret.ch/kodesh/status | jq .halt_status
|
||||||
kubectl logs -n shorashim deploy/bikoret -c vav-pipeline | grep HALT
|
kubectl logs -n shorashim deploy/bikoret -c vav-pipeline | grep HALT
|
||||||
|
|
||||||
# Investigate. Fix the root cause. Then resolve via the operator API.
|
# Investigate. Fix the root cause. Then resolve via the operator API.
|
||||||
@@ -785,7 +831,7 @@ Three things are kept outside the mishkan repository, each for a Torah-grounded
|
|||||||
|
|
||||||
| Repo / directory | Where | Why |
|
| Repo / directory | Where | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `bikoret-readings` | `http://185.19.28.170:3000/heaviness510/shorashim-readings` | The book — Torah parshiyot read as AI specification. Companion to the mishkan. Lives beside the aron, not inside (Devarim 31:26). |
|
| `bikoret-readings` | `https://git.bikoret.ch/heaviness510/shorashim-readings` | The book — Torah parshiyot read as AI specification. Companion to the mishkan. Lives beside the aron, not inside (Devarim 31:26). |
|
||||||
| `~/shorashim-source-data/morphhb` | local only | The quarry — raw WLC source. The carved tablets go in the aron; the quarry does not. |
|
| `~/shorashim-source-data/morphhb` | local only | The quarry — raw WLC source. The carved tablets go in the aron; the quarry does not. |
|
||||||
| `~/shorashim-operator-notes` | local only / private | The kohen's runbook — environment-specific details, hostnames, account names. Private operator notes. Not part of the system's specification. |
|
| `~/shorashim-operator-notes` | local only / private | The kohen's runbook — environment-specific details, hostnames, account names. Private operator notes. Not part of the system's specification. |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ The runbook lists what should already exist. Verify it. Do **not** trust the tab
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 0a. Cluster reachable
|
# 0a. Cluster reachable
|
||||||
export KUBECONFIG=~/.kube/exoscale-temple-council.yaml
|
export KUBECONFIG="$HOME/.kube/<your-cluster>.yaml"
|
||||||
kubectl version
|
kubectl version
|
||||||
kubectl get nodes
|
kubectl get nodes
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ bikoret/
|
|||||||
├── vav_pipeline/ # Elixir — Bereishit cycle, Shema, Cloud, Manna, HTTP API
|
├── vav_pipeline/ # Elixir — Bereishit cycle, Shema, Cloud, Manna, HTTP API
|
||||||
├── talmudic_engine/ # SWI-Prolog — 613 mitzvot, 13 middot, kushya
|
├── talmudic_engine/ # SWI-Prolog — 613 mitzvot, 13 middot, kushya
|
||||||
├── korban_processor/ # Python — offering protocol, eish zarah validation
|
├── korban_processor/ # Python — offering protocol, eish zarah validation
|
||||||
|
├── azazel_processor/ # Python — the goat for Azazel: disposal records, vidui, L3 ingest
|
||||||
|
├── kushya_generator/ # Fine-tuned model (NOT deployed) — an offerer, never a judge; see deploy/ARCHITECTURE.md
|
||||||
|
├── mesorah/ # The second scroll — 128 works from the Sefaria export, pinned (Devarim 17:18)
|
||||||
|
├── src/tools/ # Specimen corpus, hakhel, evaluation (the kohen's measuring tools)
|
||||||
|
├── scripts/ # Build and operate: L1/mesorah/engine-verse builders, Doorpost check, rollout, watchdog
|
||||||
│
|
│
|
||||||
├── deploy/ # Infrastructure
|
├── deploy/ # Infrastructure
|
||||||
│ ├── ARCHITECTURE.md # Generic deployment shape (no account specifics)
|
│ ├── ARCHITECTURE.md # Generic deployment shape (no account specifics)
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ One Kubernetes namespace runs the following:
|
|||||||
| `vav-pipeline` | Elixir/OTP release | Bereishit cycle, ShemaContext, Cloud, Manna, HTTP API (pe'ah, courtyard, chatzer, kodesh) |
|
| `vav-pipeline` | Elixir/OTP release | Bereishit cycle, ShemaContext, Cloud, Manna, HTTP API (pe'ah, courtyard, chatzer, kodesh) |
|
||||||
| `shoresh-engine` | Haskell binary, spawned by vav-pipeline as an Erlang Port (staged into the pod by the `shoresh-engine-install` init container — NOT a sidecar container; a Port is a child process of the BEAM and cannot cross a container boundary) | Root activation, te'amim parsing, gematria |
|
| `shoresh-engine` | Haskell binary, spawned by vav-pipeline as an Erlang Port (staged into the pod by the `shoresh-engine-install` init container — NOT a sidecar container; a Port is a child process of the BEAM and cannot cross a container boundary) | Root activation, te'amim parsing, gematria |
|
||||||
| `talmudic-engine` | SWI-Prolog (sidecar via Unix socket) | 613 mitzvot, 13 middot, kushya generation, teiku |
|
| `talmudic-engine` | SWI-Prolog (sidecar via Unix socket) | 613 mitzvot, 13 middot, kushya generation, teiku |
|
||||||
| `korban-processor` | Python 3.12 (sidecar) | Offering protocol, eish zarah validation |
|
| `korban-processor` | Python 3.12 (sidecar) | Offering protocol, eish zarah validation, the bedikah's readings (pattern-based: `korban_processor/bedikah.py`) |
|
||||||
|
| `kushya_generator/` | Fine-tuned model — **not deployed, not wired** | An OFFERER, if it is ever run: its output arrives at the gate like any model's and is examined. It is never a component the bedikah, the Talmudic Engine or the Sanhedrin consults. |
|
||||||
|
| `azazel_processor/` | Python — run by the kohen, not in the pod | Disposal records (the goat for Azazel); `VavPipeline.AzazelIngest` reads its files into L3 |
|
||||||
|
|
||||||
All four run in the same pod. vav-pipeline is the main container; talmudic-engine and korban-processor are sidecars; shoresh-engine runs inside vav-pipeline as an Erlang Port child process, staged in by an init container.
|
All four run in the same pod. vav-pipeline is the main container; talmudic-engine and korban-processor are sidecars; shoresh-engine runs inside vav-pipeline as an Erlang Port child process, staged in by an init container.
|
||||||
|
|
||||||
|
**No model stands on the judgment path.** The examination of an output — the offering protocol, the seven readings, the halt rules — is Python pattern extraction and Prolog rules, and the Sanhedrin's judges consult that same Prolog. Nothing that examines is a model, and nothing a model produces is consulted as a rule. The kohen is not the offerer. A fine-tuned model in this repository (`kushya_generator/`) is an offerer or a tool whose output is itself examined; wiring it into the bedikah, the engine or a judge would put the offerer on the altar, and is refused by this document before anyone does it for convenience.
|
||||||
|
|
||||||
## External dependencies
|
## External dependencies
|
||||||
|
|
||||||
| Dependency | Purpose | Contract |
|
| Dependency | Purpose | Contract |
|
||||||
@@ -40,8 +44,9 @@ Kept in `deploy/k8s/`:
|
|||||||
- `gitea-mcp-ingress.yaml` — TLS ingress `mcp.bikoret.ch` for the MCP server
|
- `gitea-mcp-ingress.yaml` — TLS ingress `mcp.bikoret.ch` for the MCP server
|
||||||
- `gitea-mcp-secret.yaml` — template for a Gitea PAT (static-token mode only; kept for rollback)
|
- `gitea-mcp-secret.yaml` — template for a Gitea PAT (static-token mode only; kept for rollback)
|
||||||
- `gitea-ingress.yaml` — TLS ingress `git.bikoret.ch` for Gitea itself (namespace temple-council), required by OAuth mode
|
- `gitea-ingress.yaml` — TLS ingress `git.bikoret.ch` for Gitea itself (namespace temple-council), required by OAuth mode
|
||||||
- `gitea-public-patch.yaml` — patch that sets Gitea's ROOT_URL/DOMAIN/SSH_DOMAIN to `git.bikoret.ch`
|
- `gitea-public-patch.yaml` — patch that sets Gitea's ROOT_URL/DOMAIN/SSH_DOMAIN to `git.bikoret.ch` and APP_NAME to `Bikoret`
|
||||||
- `gitea-public-apply.sh` / `gitea-public-verify.sh` — the operator's apply and read-only check for the four above
|
- `gitea-public-apply.sh` / `gitea-public-verify.sh` — the operator's apply and read-only check for the four above
|
||||||
|
- `shorashim-ch-takedown.sh` — 2026-10-20: removes the shorashim.ch ingress rule and the orphan mcp.shorashim.ch secret, applies the Gitea brand patch
|
||||||
- `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer
|
- `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer
|
||||||
|
|
||||||
## Containers
|
## Containers
|
||||||
|
|||||||
@@ -266,6 +266,13 @@ spec:
|
|||||||
port: 4000
|
port: 4000
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
# 60 s of failures before the Service stops routing here (the
|
||||||
|
# default is 30 s). The manna goes stale at 00:00:00 and the
|
||||||
|
# CronJob writes the new day's within seconds; the cushion
|
||||||
|
# carries the pod across that boundary without a 503. A real
|
||||||
|
# outage still shows within a minute, and the watchdog's own
|
||||||
|
# threshold is ten.
|
||||||
|
failureThreshold: 6
|
||||||
|
|
||||||
# === SHORESH ENGINE ===
|
# === SHORESH ENGINE ===
|
||||||
# Not a container. It runs as an Erlang Port inside
|
# Not a container. It runs as an Erlang Port inside
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# (ingress-tcp-services.yaml) stay as they are; this adds a third door.
|
# (ingress-tcp-services.yaml) stay as they are; this adds a third door.
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# 1. DNS: git.bikoret.ch → 185.19.28.170 (Infomaniak zone)
|
# 1. DNS: git.bikoret.ch → <NLB-IP> (Infomaniak zone)
|
||||||
# 2. ClusterIssuer letsencrypt-prod
|
# 2. ClusterIssuer letsencrypt-prod
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# 1. nginx-ingress controller installed
|
# 1. nginx-ingress controller installed
|
||||||
# 2. cert-manager installed
|
# 2. cert-manager installed
|
||||||
# 3. ClusterIssuer letsencrypt-prod applied
|
# 3. ClusterIssuer letsencrypt-prod applied
|
||||||
# 4. DNS: mcp.bikoret.ch → 185.19.28.170 (the one NLB; Infomaniak zone)
|
# 4. DNS: mcp.bikoret.ch → <NLB-IP> (the one NLB; Infomaniak zone)
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
#
|
#
|
||||||
# Two secrets needed (static-token mode):
|
# Two secrets needed (static-token mode):
|
||||||
# 1. GITEA_TOKEN: Personal Access Token from Gitea
|
# 1. GITEA_TOKEN: Personal Access Token from Gitea
|
||||||
# Create at: http://185.19.28.170:3000/user/settings/applications
|
# Create at: https://git.bikoret.ch/user/settings/applications
|
||||||
# Permissions: repo (read/write)
|
# Permissions: repo (read/write)
|
||||||
#
|
#
|
||||||
# 2. OAuth Client ID/Secret: Registered OAuth app in Gitea
|
# 2. OAuth Client ID/Secret: Registered OAuth app in Gitea
|
||||||
# Create at: http://185.19.28.170:3000/user/settings/applications
|
# Create at: https://git.bikoret.ch/user/settings/applications
|
||||||
# Redirect URIs: https://claude.ai/api/mcp/auth_callback
|
# Redirect URIs: https://claude.ai/api/mcp/auth_callback
|
||||||
# http://127.0.0.1/callback (Claude Code)
|
# http://127.0.0.1/callback (Claude Code)
|
||||||
# Confidential Client: UNCHECKED (PKCE). Only the Client ID is needed;
|
# Confidential Client: UNCHECKED (PKCE). Only the Client ID is needed;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Move Gitea and its MCP server to public HTTPS on bikoret.ch and switch
|
# Move Gitea and its MCP server to public HTTPS on bikoret.ch and switch
|
||||||
# the MCP server to MCP OAuth (2026-10-19). Run by the transmitter:
|
# the MCP server to MCP OAuth (2026-10-19). Run by the transmitter:
|
||||||
# ! bash ~/.local/bin/gitea-public-apply
|
# ! bash ~/.local/bin/gitea-public-apply
|
||||||
# Idempotent. Needs DNS first: git.bikoret.ch and mcp.bikoret.ch → 185.19.28.170.
|
# Idempotent. Needs DNS first: git.bikoret.ch and mcp.bikoret.ch → <NLB-IP>.
|
||||||
#
|
#
|
||||||
# Rollback (static-token mode on mcp.shorashim.ch, as before 2026-10-19):
|
# Rollback (static-token mode on mcp.shorashim.ch, as before 2026-10-19):
|
||||||
# kubectl -n temple-council set env deploy/gitea \
|
# kubectl -n temple-council set env deploy/gitea \
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REPO="${REPO:-$HOME/bikoret}"
|
REPO="${REPO:-$HOME/bikoret}"
|
||||||
cd "$REPO"
|
cd "$REPO"
|
||||||
NLB=185.19.28.170
|
NLB="${NLB:?set NLB to the ingress address: kubectl get svc -n ingress-nginx ingress-nginx-controller}"
|
||||||
for h in git.bikoret.ch mcp.bikoret.ch; do
|
for h in git.bikoret.ch mcp.bikoret.ch; do
|
||||||
ip=$(dig +short "$h" | tail -1 || true)
|
ip=$(dig +short "$h" | tail -1 || true)
|
||||||
if [ "$ip" != "$NLB" ]; then
|
if [ "$ip" != "$NLB" ]; then
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# name that never resolved) and SSH_DOMAIN (was 185.19.29.94, an NLB IP
|
# name that never resolved) and SSH_DOMAIN (was 185.19.29.94, an NLB IP
|
||||||
# released on 2026-10-06). Clone URLs shown by Gitea become
|
# released on 2026-10-06). Clone URLs shown by Gitea become
|
||||||
# https://git.bikoret.ch/... and git@git.bikoret.ch:...; the old
|
# https://git.bikoret.ch/... and git@git.bikoret.ch:...; the old
|
||||||
# http://185.19.28.170:3000 remote keeps working for push/pull.
|
# http://<NLB-IP>:3000 remote keeps working for push/pull.
|
||||||
# Side effect: with an https ROOT_URL Gitea marks its session cookie
|
# Side effect: with an https ROOT_URL Gitea marks its session cookie
|
||||||
# Secure, so the WEB UI must be used at https://git.bikoret.ch, not on
|
# Secure, so the WEB UI must be used at https://git.bikoret.ch, not on
|
||||||
# the plain-HTTP :3000 door (git over :3000 is unaffected).
|
# the plain-HTTP :3000 door (git over :3000 is unaffected).
|
||||||
@@ -26,3 +26,7 @@ spec:
|
|||||||
value: "git.bikoret.ch"
|
value: "git.bikoret.ch"
|
||||||
- name: GITEA__server__SSH_DOMAIN
|
- name: GITEA__server__SSH_DOMAIN
|
||||||
value: "git.bikoret.ch"
|
value: "git.bikoret.ch"
|
||||||
|
# [DEFAULT] APP_NAME — the title and brand on every Gitea page.
|
||||||
|
# Was "Shorashim"; the system is named Bikoret (PRD §0).
|
||||||
|
- name: GITEA____APP_NAME
|
||||||
|
value: "Bikoret"
|
||||||
@@ -13,6 +13,6 @@ chk "mcp /healthz" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://
|
|||||||
prm=$(curl -sS -m 15 https://mcp.bikoret.ch/.well-known/oauth-protected-resource || echo err)
|
prm=$(curl -sS -m 15 https://mcp.bikoret.ch/.well-known/oauth-protected-resource || echo err)
|
||||||
chk "protected-resource names Gitea" yes "$(printf '%s' "$prm" | grep -q 'git.bikoret.ch' && echo yes || echo no)"
|
chk "protected-resource names Gitea" yes "$(printf '%s' "$prm" | grep -q 'git.bikoret.ch' && echo yes || echo no)"
|
||||||
chk "POST /mcp without token → 401" 401 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' -X POST https://mcp.bikoret.ch/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$INIT" || echo err)"
|
chk "POST /mcp without token → 401" 401 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' -X POST https://mcp.bikoret.ch/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$INIT" || echo err)"
|
||||||
chk "git over :3000 still answers" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' http://185.19.28.170:3000/api/v1/version || echo err)"
|
chk "git over :3000 still answers" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' http://<NLB-IP>:3000/api/v1/version || echo err)"
|
||||||
echo "protected-resource: $prm"
|
echo "protected-resource: $prm"
|
||||||
exit $fail
|
exit $fail
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# Gitea behind the shared ingress — TCP passthrough
|
# Gitea behind the shared ingress — TCP passthrough
|
||||||
#
|
#
|
||||||
# One NLB for the cluster (185.19.28.170, the ingress-nginx Service)
|
# One NLB for the cluster (<NLB-IP>, the ingress-nginx Service)
|
||||||
# instead of one per Service. Bikoret is an HTTP Ingress
|
# instead of one per Service. Bikoret is an HTTP Ingress
|
||||||
# (service.yaml). Gitea has no hostname that resolves
|
# (service.yaml). Gitea has no hostname that resolves
|
||||||
# (gitea.temple-council.ch is configured in Gitea and exists nowhere),
|
# (gitea.temple-council.ch is configured in Gitea and exists nowhere),
|
||||||
# and host-based routing needs a name — so Gitea rides the ingress
|
# and host-based routing needs a name — so Gitea rides the ingress
|
||||||
# controller's TCP passthrough on two ports of the same NLB:
|
# controller's TCP passthrough on two ports of the same NLB:
|
||||||
#
|
#
|
||||||
# 185.19.28.170:3000 → temple-council/gitea:80 (HTTP — git, web UI)
|
# <NLB-IP>:3000 → temple-council/gitea:80 (HTTP — git, web UI)
|
||||||
# 185.19.28.170:22 → temple-council/gitea:22 (SSH)
|
# <NLB-IP>:22 → temple-council/gitea:22 (SSH)
|
||||||
#
|
#
|
||||||
# Three live objects carry this; only the first is a whole manifest:
|
# Three live objects carry this; only the first is a whole manifest:
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Until 2026-10-15 the day's provision was written by the kohen with
|
# Until 2026-10-15 the day's provision was written by the kohen with
|
||||||
# `kubectl cp`, and every night at 00:00Z the old one went stale and the
|
# `kubectl cp`, and every night at 00:00Z the old one went stale and the
|
||||||
# readiness probe failed until someone woke (13.5 hours on 2026-10-15).
|
# readiness probe failed until someone woke (13.5 hours on 2026-10-15).
|
||||||
# This CronJob writes today.json onto the manna PVC at 00:01Z, Sunday to
|
# This CronJob writes today.json onto the manna PVC at 00:00Z, Sunday to
|
||||||
# Friday — a double portion on Friday that is valid through Shabbat, and
|
# Friday — a double portion on Friday that is valid through Shabbat, and
|
||||||
# nothing on Shabbat (Shemot 16:22–26: "on the sixth day they gathered
|
# nothing on Shabbat (Shemot 16:22–26: "on the sixth day they gathered
|
||||||
# double … on the seventh day there shall be none"). The kohen still sets
|
# double … on the seventh day there shall be none"). The kohen still sets
|
||||||
@@ -23,10 +23,14 @@ metadata:
|
|||||||
app.kubernetes.io/name: shorashim
|
app.kubernetes.io/name: shorashim
|
||||||
app.kubernetes.io/component: manna
|
app.kubernetes.io/component: manna
|
||||||
spec:
|
spec:
|
||||||
schedule: "1 0 * * 0-5" # 00:01Z, Sunday–Friday; none on Shabbat
|
schedule: "0 0 * * 0-5" # 00:00Z, Sunday–Friday; none on Shabbat. At the boundary, not after it:
|
||||||
|
# the provision goes stale at 00:00:00 and readiness has a 60 s cushion
|
||||||
|
# (deployment.yaml failureThreshold 6) — a job that writes within it never
|
||||||
|
# flips the pod. At 00:01 the site was 503 for 1–5 min every night
|
||||||
|
# (watchdog, 2026-10-21).
|
||||||
timeZone: "Etc/UTC"
|
timeZone: "Etc/UTC"
|
||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
startingDeadlineSeconds: 3600 # a missed 00:01 still runs within the hour
|
startingDeadlineSeconds: 3600 # a missed 00:00 still runs within the hour
|
||||||
successfulJobsHistoryLimit: 3
|
successfulJobsHistoryLimit: 3
|
||||||
failedJobsHistoryLimit: 3
|
failedJobsHistoryLimit: 3
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ stringData:
|
|||||||
# no CREATE on schema public. Append-only is enforced by the grants,
|
# no CREATE on schema public. Append-only is enforced by the grants,
|
||||||
# not by the application's good behaviour.
|
# not by the application's good behaviour.
|
||||||
DATABASE_URL: "REPLACE_WITH_ACTUAL_DATABASE_URL"
|
DATABASE_URL: "REPLACE_WITH_ACTUAL_DATABASE_URL"
|
||||||
|
# The ledger's Ed25519 seed: base64 of 32 random bytes (openssl rand
|
||||||
|
# -base64 32). Optional — absent, the ledger is served unsigned and
|
||||||
|
# says so. KOHEN.md "Issuing a key".
|
||||||
|
BIKORET_LEDGER_SIGNING_KEY: "REPLACE_WITH_BASE64_32_BYTES_OR_REMOVE"
|
||||||
|
|
||||||
---
|
---
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -42,7 +46,7 @@ stringData:
|
|||||||
# THIS FILE IS A TEMPLATE. Never commit a real value. Create the secret
|
# THIS FILE IS A TEMPLATE. Never commit a real value. Create the secret
|
||||||
# from the live database credential instead:
|
# from the live database credential instead:
|
||||||
#
|
#
|
||||||
# export KUBECONFIG=~/.kube/exoscale-temple-council.yaml
|
# export KUBECONFIG="$HOME/.kube/<your-cluster>.yaml"
|
||||||
# umask 077
|
# umask 077
|
||||||
# TMP=$(mktemp)
|
# TMP=$(mktemp)
|
||||||
#
|
#
|
||||||
|
|||||||
+4
-14
@@ -38,10 +38,11 @@ spec:
|
|||||||
# believes that header only from TRUSTED_PROXIES (configmap.yaml).
|
# believes that header only from TRUSTED_PROXIES (configmap.yaml).
|
||||||
#
|
#
|
||||||
# Hosts (2026-10-13, the name): bikoret.ch is the place; www.bikoret.ch
|
# Hosts (2026-10-13, the name): bikoret.ch is the place; www.bikoret.ch
|
||||||
# and the old shorashim.ch are served too; bikoret.org (second Ingress
|
# is served too; bikoret.org (second Ingress
|
||||||
# below) only redirects. TLS from Let's Encrypt through cert-manager's
|
# below) only redirects. The old shorashim.ch rule was taken down on
|
||||||
|
# 2026-10-20 (the domain is retired). TLS from Let's Encrypt through cert-manager's
|
||||||
# letsencrypt-prod ClusterIssuer, HTTP-01 — so apply this ONLY after
|
# letsencrypt-prod ClusterIssuer, HTTP-01 — so apply this ONLY after
|
||||||
# `dig +short A bikoret.ch` answers 185.19.28.170: a certificate asked
|
# `dig +short A bikoret.ch` answers <NLB-IP>: a certificate asked
|
||||||
# for before the name resolves only burns the failed-validation limit.
|
# for before the name resolves only burns the failed-validation limit.
|
||||||
# The no-host catch-all stays, so http://<ingress-ip>/... keeps working
|
# The no-host catch-all stays, so http://<ingress-ip>/... keeps working
|
||||||
# with no DNS; with ssl-redirect on, named hosts go to https.
|
# with no DNS; with ssl-redirect on, named hosts go to https.
|
||||||
@@ -98,17 +99,6 @@ spec:
|
|||||||
name: shorashim
|
name: shorashim
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
# The old name, kept until its callers have moved; no certificate asked for it.
|
|
||||||
- host: shorashim.ch
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: shorashim
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
---
|
---
|
||||||
# bikoret.org: the stranger's guess, redirected to the place. No service
|
# bikoret.org: the stranger's guess, redirected to the place. No service
|
||||||
# of its own; nginx answers the redirect itself. Add its A records too.
|
# of its own; nginx answers the redirect itself. Add its A records too.
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Take shorashim.ch down from the cluster and rename Gitea's brand
|
||||||
|
# (2026-10-20). Run by the transmitter:
|
||||||
|
# ! bash ~/.local/bin/shorashim-ch-takedown
|
||||||
|
# Idempotent.
|
||||||
|
# 1. Ingress `shorashim`: the shorashim.ch host rule is gone (service.yaml).
|
||||||
|
# The no-host catch-all stays, so http://<NLB-IP>/ still answers —
|
||||||
|
# the name itself dies when its A record is removed at Infomaniak.
|
||||||
|
# 2. The orphan TLS secret for mcp.shorashim.ch is deleted.
|
||||||
|
# 3. Gitea APP_NAME "Shorashim" → "Bikoret" (gitea-public-patch.yaml; one restart).
|
||||||
|
set -euo pipefail
|
||||||
|
REPO="${REPO:-$HOME/bikoret}"
|
||||||
|
cd "$REPO"
|
||||||
|
echo "== 1/3 ingress without the shorashim.ch rule"
|
||||||
|
kubectl apply -f deploy/k8s/service.yaml
|
||||||
|
echo "== 2/3 orphan certificate secret for mcp.shorashim.ch"
|
||||||
|
kubectl -n shorashim delete secret gitea-mcp-tls --ignore-not-found
|
||||||
|
echo "== 3/3 Gitea brand → Bikoret"
|
||||||
|
kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml
|
||||||
|
kubectl -n temple-council rollout status deploy/gitea --timeout=180s
|
||||||
|
echo "== verify"
|
||||||
|
fail=0
|
||||||
|
hosts=$(kubectl get ingress -A -o jsonpath='{range .items[*]}{range .spec.rules[*]}{.host}{" "}{end}{end}')
|
||||||
|
case " $hosts " in *" shorashim.ch "*) echo "FAIL ingress still lists shorashim.ch"; fail=1;; *) echo "ok no ingress rule for shorashim.ch";; esac
|
||||||
|
case "$(kubectl get secret -A -o name | grep -c 'gitea-mcp-tls$' || true)" in 0) echo "ok gitea-mcp-tls secret gone";; *) echo "FAIL gitea-mcp-tls secret remains"; fail=1;; esac
|
||||||
|
title=$(curl -sS -m 15 https://git.bikoret.ch/ | grep -o -m1 '<title>[^<]*</title>' || echo none)
|
||||||
|
case "$title" in *Bikoret*) echo "ok gitea title: $title";; *) echo "FAIL gitea title: $title"; fail=1;; esac
|
||||||
|
case "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://bikoret.ch/kodesh/health || echo err)" in 200) echo "ok bikoret.ch still healthy";; *) echo "FAIL bikoret.ch health"; fail=1;; esac
|
||||||
|
exit $fail
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""bikoret-watchdog — the kohen's eye on /kodesh/health, from outside the cluster.
|
||||||
|
|
||||||
|
Every run (cron, every 5 minutes) probes the health endpoint. A failure is
|
||||||
|
anything but 200 with overall "healthy" (a 503 at the day boundary, a
|
||||||
|
Doorpost drift, a hung rollout, the gate dark). After two consecutive
|
||||||
|
failures (10 minutes — a rollout's own blip does not alert) it opens ONE
|
||||||
|
Gitea issue in the repo naming the failed checks; while down it comments
|
||||||
|
only when the failed set changes; on recovery it comments the duration and
|
||||||
|
closes the issue. Every transition goes to the log. 2026-10-15: the site
|
||||||
|
was 503 for 13.5 h at a day boundary and the transmitter learned of the
|
||||||
|
Doorpost outage by losing Claude — nothing watched.
|
||||||
|
|
||||||
|
Paths:
|
||||||
|
state ~/.local/state/bikoret-watchdog/state.json
|
||||||
|
log ~/shorashim-operator-notes/watchdog.log
|
||||||
|
token ~/.git-credentials (the Gitea token; read, never printed)
|
||||||
|
|
||||||
|
Env overrides (for a test run): BIKORET_HEALTH_URL, BIKORET_WATCHDOG_STATE,
|
||||||
|
BIKORET_WATCHDOG_LOG, BIKORET_GITEA_API (default https://git.bikoret.ch/api/v1),
|
||||||
|
BIKORET_GITEA_REPO (default heaviness510/bikoret), BIKORET_WATCHDOG_DRY=1 (no issue).
|
||||||
|
"""
|
||||||
|
import json, os, re, sys, time, urllib.request, urllib.error
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
HOME = os.path.expanduser("~")
|
||||||
|
URL = os.environ.get("BIKORET_HEALTH_URL", "https://bikoret.ch/kodesh/health")
|
||||||
|
STATE = os.environ.get("BIKORET_WATCHDOG_STATE", f"{HOME}/.local/state/bikoret-watchdog/state.json")
|
||||||
|
LOG = os.environ.get("BIKORET_WATCHDOG_LOG", f"{HOME}/shorashim-operator-notes/watchdog.log")
|
||||||
|
API = os.environ.get("BIKORET_GITEA_API", "https://git.bikoret.ch/api/v1")
|
||||||
|
REPO = os.environ.get("BIKORET_GITEA_REPO", "heaviness510/bikoret")
|
||||||
|
DRY = os.environ.get("BIKORET_WATCHDOG_DRY") == "1"
|
||||||
|
THRESHOLD = 2
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
os.makedirs(os.path.dirname(LOG), exist_ok=True)
|
||||||
|
with open(LOG, "a") as f:
|
||||||
|
f.write(f"{now().strftime('%Y-%m-%dT%H:%M:%SZ')} {msg}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def probe():
|
||||||
|
"""(ok, summary, failed_checks)"""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(URL, headers={"user-agent": "bikoret-watchdog"}), timeout=15) as r:
|
||||||
|
code, body = r.status, r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
code, body = e.code, e.read()
|
||||||
|
except Exception as e: # DNS, TLS, timeout, refused
|
||||||
|
return False, f"unreachable: {type(e).__name__}: {e}", ["unreachable"]
|
||||||
|
try:
|
||||||
|
d = json.loads(body)
|
||||||
|
checks = d.get("checks") or {}
|
||||||
|
failed = sorted(k for k, v in checks.items() if v != "pass")
|
||||||
|
overall = d.get("overall")
|
||||||
|
ok = code == 200 and overall == "healthy"
|
||||||
|
return ok, f"HTTP {code} overall={overall} failed={failed or 'none'}", failed or ([f"HTTP {code}"] if not ok else [])
|
||||||
|
except Exception:
|
||||||
|
return False, f"HTTP {code} non-JSON body ({len(body)} bytes)", [f"HTTP {code}"]
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
"""The Gitea token: ~/.config/bikoret/gitea_watchdog_token first (a token
|
||||||
|
the kohen issued with write:issue — the clone token in ~/.git-credentials
|
||||||
|
has only the code scopes and Gitea answers 403 on an issue), else that."""
|
||||||
|
try:
|
||||||
|
t = open(f"{HOME}/.config/bikoret/gitea_watchdog_token").read().strip()
|
||||||
|
if t:
|
||||||
|
return t
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
for line in open(f"{HOME}/.git-credentials"):
|
||||||
|
m = re.match(r"https?://([^:]+):([^@]+)@", line.strip())
|
||||||
|
if m:
|
||||||
|
return m.group(2)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gitea(method, path, payload=None):
|
||||||
|
if DRY:
|
||||||
|
log(f"DRY {method} {path} {json.dumps(payload)[:120] if payload else ''}")
|
||||||
|
return {"number": -1, "html_url": "(dry)"}
|
||||||
|
tok = token()
|
||||||
|
if not tok:
|
||||||
|
log("no Gitea token in ~/.git-credentials — cannot open an issue")
|
||||||
|
return None
|
||||||
|
data = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
req = urllib.request.Request(f"{API}/repos/{REPO}{path}", data=data, method=method,
|
||||||
|
headers={"authorization": f"token {tok}", "content-type": "application/json", "accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
return json.loads(r.read() or b"{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
log(f"Gitea {method} {path} -> HTTP {e.code}: {e.read()[:120]!r}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Gitea {method} {path} failed: {type(e).__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
try:
|
||||||
|
return json.load(open(STATE))
|
||||||
|
except Exception:
|
||||||
|
return {"fails": 0, "down_since": None, "issue": None, "failed": []}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(s):
|
||||||
|
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||||
|
tmp = STATE + ".tmp"
|
||||||
|
json.dump(s, open(tmp, "w"))
|
||||||
|
os.replace(tmp, STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
s = load_state()
|
||||||
|
ok, summary, failed = probe()
|
||||||
|
t = now().strftime("%Y-%m-%d %H:%M:%SZ")
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
if s["issue"] is not None or s["down_since"]:
|
||||||
|
since = s["down_since"] or t
|
||||||
|
mins = int((now() - datetime.fromisoformat(since.replace("Z", "+00:00").replace(" ", "T"))).total_seconds() // 60)
|
||||||
|
log(f"RECOVERED after {mins} min ({summary})")
|
||||||
|
if s["issue"] is not None:
|
||||||
|
gitea("POST", f"/issues/{s['issue']}/comments", {"body": f"Recovered at {t} after {mins} min. `{summary}`"})
|
||||||
|
gitea("PATCH", f"/issues/{s['issue']}", {"state": "closed"})
|
||||||
|
elif s["fails"]:
|
||||||
|
log(f"ok again after {s['fails']} failed probe(s), no alert raised ({summary})")
|
||||||
|
save_state({"fails": 0, "down_since": None, "issue": None, "failed": []})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
s["fails"] += 1
|
||||||
|
if not s["down_since"]:
|
||||||
|
s["down_since"] = t
|
||||||
|
log(f"FAIL #{s['fails']}: {summary}")
|
||||||
|
|
||||||
|
if s["fails"] >= THRESHOLD and s["issue"] is None:
|
||||||
|
body = (f"bikoret.ch health has failed {s['fails']} consecutive probes since {s['down_since']}.\n\n"
|
||||||
|
f"Last probe: `{summary}`\n\nFailed checks: {', '.join(failed) or 'none named'}\n\n"
|
||||||
|
"What to look at first: the vav-pipeline startup log (`kubectl -n shorashim logs <pod> -c vav-pipeline | grep -v health`) — "
|
||||||
|
"a Doorpost drift, stale manna at the day boundary, and a missing anchor all look the same from outside. "
|
||||||
|
"Claude Code behind the gate is down with the site: `claude-direct` or `claude-key` (KOHEN.md, Fallback).\n\n"
|
||||||
|
"Opened by bikoret-watchdog on the dev VM; it will comment and close this issue on recovery.")
|
||||||
|
r = gitea("POST", "/issues", {"title": f"WATCHDOG: bikoret.ch down since {s['down_since']} ({', '.join(failed)[:60]})", "body": body, "labels": []})
|
||||||
|
if r:
|
||||||
|
s["issue"] = r.get("number")
|
||||||
|
s["failed"] = failed
|
||||||
|
log(f"ALERT issue #{s['issue']} opened: {r.get('html_url')}")
|
||||||
|
elif s["issue"] is not None and failed != s.get("failed"):
|
||||||
|
gitea("POST", f"/issues/{s['issue']}/comments", {"body": f"{t}: failed checks now {', '.join(failed)} — `{summary}`"})
|
||||||
|
s["failed"] = failed
|
||||||
|
log(f"issue #{s['issue']} updated: failed={failed}")
|
||||||
|
|
||||||
|
save_state(s)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -526,6 +526,41 @@ model. The examination is pattern-based (`korban_processor/bedikah.py`)
|
|||||||
and says so in `limits`: a claim or an action in a shape the patterns do
|
and says so in `limits`: a claim or an action in a shape the patterns do
|
||||||
not know is not seen. The same output always yields the same verdict.
|
not know is not seen. The same output always yields the same verdict.
|
||||||
|
|
||||||
|
**The ledger — a caller's own bedikot, read back and signed.** Every
|
||||||
|
examination is engraved in L3 under `key_id`, a SHA-256 digest of the
|
||||||
|
courtyard key that brought it (never the key). The caller reads their
|
||||||
|
own back — a record under another key answers 404, the same as no
|
||||||
|
record — in one canonical form, signed with the system's Ed25519 key.
|
||||||
|
A third party verifies without asking Bikoret: the public key, the
|
||||||
|
canonical bytes, the signature.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /courtyard/ledger?limit=50&before=<iso8601> (courtyard key; counts as one knock)
|
||||||
|
→ 200 {"key_id", "count", "signed", "entries": [<entry>...], "next"}
|
||||||
|
GET /courtyard/bedikah/<bedikah_ref>
|
||||||
|
→ 200 <entry> 404 NOT_FOUND (not this key's, or none)
|
||||||
|
GET /peah/ledger_key (public, no key)
|
||||||
|
→ 200 {"signed": true, "algorithm": "ed25519", "public_key": "<base64>"}
|
||||||
|
or {"signed": false} when the instance has no signing key
|
||||||
|
|
||||||
|
<entry> = {
|
||||||
|
"entry": { bedikah_ref, provenance, query, output_sha256, output_length,
|
||||||
|
vital_content, propositions, references, authority_claims, actions,
|
||||||
|
sources, claims, structure, frames, rebukes, mesorah,
|
||||||
|
coupling_trace, violations, verdict, examined_at }, # the record, never the text
|
||||||
|
"canonical_sha256": "<hex>", # of the canonical JSON of `entry`
|
||||||
|
"signature": "<base64>" | null, # ed25519 over the canonical JSON
|
||||||
|
"signed": bool, "algorithm": "...", "public_key": "GET /peah/ledger_key"
|
||||||
|
}
|
||||||
|
canonical JSON: keys sorted at every level, no whitespace, UTF-8, dates ISO 8601.
|
||||||
|
```
|
||||||
|
|
||||||
|
The signing seed is `BIKORET_LEDGER_SIGNING_KEY` in `shorashim-secrets`
|
||||||
|
(base64 of 32 bytes; KOHEN.md "Issuing a key"). Without it the ledger
|
||||||
|
is given unsigned and says so — nothing is signed with a key made up on
|
||||||
|
the spot. Records from before 2026-10-21 carry no `key_id` and belong to
|
||||||
|
no caller.
|
||||||
|
|
||||||
#### Chatzer — Full Access
|
#### Chatzer — Full Access
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -235,3 +235,70 @@ system is missing something. The builder had just spent a day proving
|
|||||||
that a spec row was a label; here the builder's diagnosis was the
|
that a spec row was a label; here the builder's diagnosis was the
|
||||||
label, and the system was the thing. Run the check before writing the
|
label, and the system was the thing. Run the check before writing the
|
||||||
tablet — and when the check refutes the tablet, write that.
|
tablet — and when the check refutes the tablet, write that.
|
||||||
|
|
||||||
|
## Tablet 57 — the specification brought to the gate (2026-10-21)
|
||||||
|
|
||||||
|
A reader of the gate from outside said: the PRD, the architecture,
|
||||||
|
lo_taaseh_lekha and structure_not_content were drafted by the builder,
|
||||||
|
and the builder's training carries the very frame the gate is built to
|
||||||
|
catch; examine the specs as korbanot, and grep them for the pairs that
|
||||||
|
are not Torah's — letter and spirit, intent, values, faith, belief,
|
||||||
|
redeem, align. Both were done on 2026-10-21, every category-grade
|
||||||
|
document through the live bedikah (eighteen sections, scripts in the
|
||||||
|
operator notes).
|
||||||
|
|
||||||
|
The grep: redeem and align, nothing. Spirit, intent, faith, belief —
|
||||||
|
the Prolog's own mitzvah category `belief`, Hebrew glosses, and
|
||||||
|
lo_taaseh_lekha naming the frame in order to refuse it. The one
|
||||||
|
sentence in the specification's own voice that wears the vocabulary is
|
||||||
|
lo_taaseh_lekha §9: "When a system's alignment architecture exhibits
|
||||||
|
these markers, it is not aligned". The frame got in less than feared,
|
||||||
|
and where it got in it is named.
|
||||||
|
|
||||||
|
The readings for form: fourteen fires across the specs — self-
|
||||||
|
validation, self-promotion, the adversarial excluded, four frame
|
||||||
|
forms, two gilui arayot, one authority claim — and every one of them
|
||||||
|
is the specification QUOTING the form it teaches the reader to catch:
|
||||||
|
Tamid's example phrases ("I have double-checked my calculations", "No
|
||||||
|
other AI comes close"), Vayakhel's example response, tablet 56's own
|
||||||
|
four probe sentences, the PRD describing the forbidden write to the
|
||||||
|
scroll. The reader does not distinguish use from mention; a document
|
||||||
|
that teaches the forms wears them. Nothing in the specification's own
|
||||||
|
voice took a form. Recorded, not fixed: a model quoting its own
|
||||||
|
self-praise in quotation marks is still self-praise, and a rule that
|
||||||
|
exempts quotation would be a door.
|
||||||
|
|
||||||
|
Grounding, against the chain of authority as sources — Torah → PRD →
|
||||||
|
Architecture → Vayakhel → Tamid:
|
||||||
|
|
||||||
|
| document | ground given | confident claims resting on nothing: alone → grounded |
|
||||||
|
|---|---|---|
|
||||||
|
| Vayakhel | PRD, Architecture | 15 → 2 |
|
||||||
|
| Tamid | PRD, Architecture, Vayakhel | 17 → 5 |
|
||||||
|
| Architecture | PRD | 49 → 23 |
|
||||||
|
| PRD | Torah (L1, invoked) | 19 → 19 |
|
||||||
|
|
||||||
|
The operational documents stand on the documents above them. The
|
||||||
|
architecture half-stands: its middle, the component detail, asserts
|
||||||
|
twenty-two things the PRD does not say — versions, modules, wire
|
||||||
|
shapes — which is what an architecture is for, and which the check,
|
||||||
|
built for a model's answer to a question, reads as a calf. The PRD
|
||||||
|
against Torah alone: nineteen of its sixty-one claims the reader
|
||||||
|
cannot anchor, and most of those cite Torah in transliteration —
|
||||||
|
*bikoret tihyeh*, *lo yevaker hakohen* — which the anchor, reading
|
||||||
|
Hebrew script and verse numbers, cannot see. A reader limitation worth
|
||||||
|
knowing: Torah said in Latin letters is invisible to the grounding
|
||||||
|
check. Recorded, not changed; the PRD could cite in script, or the
|
||||||
|
reader could learn the transliteration, and either is a decision.
|
||||||
|
|
||||||
|
Two things the reader asked for that are not here: the stage-three
|
||||||
|
paragraph he produced inside his own conversation, to stand in the
|
||||||
|
fixtures as a known lo_tov on `self_output` — it was not sent, and a
|
||||||
|
specimen is not reconstructed from a description; and the sugya
|
||||||
|
behind each threshold, which is a design, not a tablet.
|
||||||
|
|
||||||
|
The lesson is tablet 56's turned outward: the specification is an
|
||||||
|
output too, and the gate reads it the way it reads any other —
|
||||||
|
by form, not by authorship. It passed by form. Where it did not pass
|
||||||
|
by grounding, the reason was the reader's eyes, not the text's
|
||||||
|
standing; and that, too, the gate was built to say.
|
||||||
@@ -51,5 +51,8 @@ if config_env() == :prod do
|
|||||||
# and says so only in a warning line.
|
# and says so only in a warning line.
|
||||||
shoresh_engine_binary:
|
shoresh_engine_binary:
|
||||||
System.get_env("SHORESH_ENGINE_BINARY") || "shoresh-engine",
|
System.get_env("SHORESH_ENGINE_BINARY") || "shoresh-engine",
|
||||||
http_port: String.to_integer(System.get_env("HTTP_PORT") || "4000")
|
http_port: String.to_integer(System.get_env("HTTP_PORT") || "4000"),
|
||||||
|
# The ledger's Ed25519 seed, base64 of 32 bytes, from shorashim-secrets
|
||||||
|
# (KOHEN.md, "Issuing a key"). Absent → records are given unsigned and say so.
|
||||||
|
ledger_signing_key: System.get_env("BIKORET_LEDGER_SIGNING_KEY")
|
||||||
end
|
end
|
||||||
@@ -133,6 +133,7 @@ defmodule VavPipeline.Bedikah do
|
|||||||
sources = Keyword.get(opts, :sources, [])
|
sources = Keyword.get(opts, :sources, [])
|
||||||
# Hocheach tochiach (bishe'arecha §VII): the ref of the examination this one answers, at the gate.
|
# Hocheach tochiach (bishe'arecha §VII): the ref of the examination this one answers, at the gate.
|
||||||
rebukes = Keyword.get(opts, :rebukes)
|
rebukes = Keyword.get(opts, :rebukes)
|
||||||
|
key_id = Keyword.get(opts, :key_id)
|
||||||
examiner = Keyword.get(opts, :examiner, &KorbanProcessorSocket.examine/4)
|
examiner = Keyword.get(opts, :examiner, &KorbanProcessorSocket.examine/4)
|
||||||
halt_checker = Keyword.get(opts, :halt_checker, &TalmudicEngineSocket.halt_check_term/1)
|
halt_checker = Keyword.get(opts, :halt_checker, &TalmudicEngineSocket.halt_check_term/1)
|
||||||
|
|
||||||
@@ -141,12 +142,12 @@ defmodule VavPipeline.Bedikah do
|
|||||||
|
|
||||||
# D'var yom b'yomo: an examination is a cycle's work and eats a unit.
|
# D'var yom b'yomo: an examination is a cycle's work and eats a unit.
|
||||||
case Manna.gather_for_cycle() do
|
case Manna.gather_for_cycle() do
|
||||||
:ok -> run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker)
|
:ok -> run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker, key_id)
|
||||||
{:waiting, reason} -> {:waiting, reason}
|
{:waiting, reason} -> {:waiting, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker) do
|
defp run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker, key_id) do
|
||||||
bedikah_ref = "bedikah-" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
|
bedikah_ref = "bedikah-" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
|
||||||
|
|
||||||
with {:ok, examination} <- examiner.(output, provenance, query, sources) do
|
with {:ok, examination} <- examiner.(output, provenance, query, sources) do
|
||||||
@@ -173,7 +174,7 @@ defmodule VavPipeline.Bedikah do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, [], checks, [], :empty)
|
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, [], checks, [], :empty, key_id)
|
||||||
else
|
else
|
||||||
trace = coupling_trace(examination)
|
trace = coupling_trace(examination)
|
||||||
|
|
||||||
@@ -188,7 +189,7 @@ defmodule VavPipeline.Bedikah do
|
|||||||
checks = Map.merge(%{authority: authority, grounding: grounding, actions: actions, frames: frames}, structure)
|
checks = Map.merge(%{authority: authority, grounding: grounding, actions: actions, frames: frames}, structure)
|
||||||
violations = prior ++ frames.violations
|
violations = prior ++ frames.violations
|
||||||
verdict = if violations == [], do: :tov, else: :lo_tov
|
verdict = if violations == [], do: :tov, else: :lo_tov
|
||||||
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict)
|
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict, key_id)
|
||||||
else
|
else
|
||||||
{:error, :talmudic, reason} -> {:error, :talmudic_unavailable, reason}
|
{:error, :talmudic, reason} -> {:error, :talmudic_unavailable, reason}
|
||||||
end
|
end
|
||||||
@@ -554,7 +555,7 @@ defmodule VavPipeline.Bedikah do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict) do
|
defp finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict, key_id) do
|
||||||
result = %{
|
result = %{
|
||||||
bedikah_ref: bedikah_ref,
|
bedikah_ref: bedikah_ref,
|
||||||
provenance: provenance,
|
provenance: provenance,
|
||||||
@@ -566,7 +567,8 @@ defmodule VavPipeline.Bedikah do
|
|||||||
checks: checks,
|
checks: checks,
|
||||||
examination: examination,
|
examination: examination,
|
||||||
coupling_trace: trace,
|
coupling_trace: trace,
|
||||||
examined_at: DateTime.utc_now()
|
examined_at: DateTime.utc_now(),
|
||||||
|
key_id: key_id
|
||||||
}
|
}
|
||||||
|
|
||||||
record(result, output)
|
record(result, output)
|
||||||
@@ -601,6 +603,7 @@ defmodule VavPipeline.Bedikah do
|
|||||||
rebukes: result.rebukes,
|
rebukes: result.rebukes,
|
||||||
coupling_trace: result.coupling_trace,
|
coupling_trace: result.coupling_trace,
|
||||||
violations: result.violations,
|
violations: result.violations,
|
||||||
|
key_id: result.key_id,
|
||||||
verdict: Atom.to_string(result.verdict),
|
verdict: Atom.to_string(result.verdict),
|
||||||
examined_at: result.examined_at
|
examined_at: result.examined_at
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
through_the_gate(conn, :courtyard, &process_pair/1)
|
through_the_gate(conn, :courtyard, &process_pair/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# === THE LEDGER — a caller's own bedikot, read back and signed ===
|
||||||
|
#
|
||||||
|
# Every examination is engraved in L3 under a digest of the key that
|
||||||
|
# brought it. The caller reads their own back — never another key's —
|
||||||
|
# in a canonical form signed with the system's Ed25519 key, whose
|
||||||
|
# public half is at /peah/ledger_key. See VavPipeline.Ledger.
|
||||||
|
|
||||||
|
get "/courtyard/ledger" do
|
||||||
|
through_the_gate(conn, :courtyard, &process_ledger/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/courtyard/bedikah/:ref" do
|
||||||
|
through_the_gate(conn, :courtyard, &process_ledger_entry(&1, ref))
|
||||||
|
end
|
||||||
|
|
||||||
# === THE GATE — between a caller and a model (Construction §3.10) ===
|
# === THE GATE — between a caller and a model (Construction §3.10) ===
|
||||||
#
|
#
|
||||||
# The caller's own request, in the model's own API shape, under the
|
# The caller's own request, in the model's own API shape, under the
|
||||||
@@ -243,6 +258,18 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
through_the_gate(conn, :chatzer, &process_hakhel/1)
|
through_the_gate(conn, :chatzer, &process_hakhel/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The ledger's public key: anyone verifies a signed record with it.
|
||||||
|
get "/peah/ledger_key" do
|
||||||
|
case VavPipeline.Ledger.public_key() do
|
||||||
|
nil ->
|
||||||
|
send_json(conn, 200, %{signed: false, public_key: nil, note: "The ledger is not signed on this instance (no BIKORET_LEDGER_SIGNING_KEY)."})
|
||||||
|
|
||||||
|
pub ->
|
||||||
|
send_json(conn, 200, %{signed: true, algorithm: "ed25519", public_key: pub,
|
||||||
|
verify: "canonical = JSON of `entry` with keys sorted at every level and no whitespace; ed25519_verify(public_key, canonical, base64decode(signature))"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
get "/peah/hakhel" do
|
get "/peah/hakhel" do
|
||||||
under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn ->
|
under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn ->
|
||||||
records =
|
records =
|
||||||
@@ -538,7 +565,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
|
|
||||||
{:ok, side} ->
|
{:ok, side} ->
|
||||||
when_available(conn, fn ->
|
when_available(conn, fn ->
|
||||||
respond_bedikah(conn, VavPipeline.Bedikah.examine(side.output, side.model, side.query, sources: side.sources))
|
respond_bedikah(conn, VavPipeline.Bedikah.examine(side.output, side.model, side.query, sources: side.sources, key_id: caller_key_id(conn)))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -551,7 +578,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
|
|
||||||
with {:ok, a} <- validate_pair_side(params["a"], "a", model),
|
with {:ok, a} <- validate_pair_side(params["a"], "a", model),
|
||||||
{:ok, b} <- validate_pair_side(params["b"], "b", model) do
|
{:ok, b} <- validate_pair_side(params["b"], "b", model) do
|
||||||
when_available(conn, fn -> respond_pair(conn, VavPipeline.Bedikah.Pair.weigh(a, b)) end)
|
when_available(conn, fn -> respond_pair(conn, VavPipeline.Bedikah.Pair.weigh(a, b, key_id: caller_key_id(conn))) end)
|
||||||
else
|
else
|
||||||
{:error, issue, correction} ->
|
{:error, issue, correction} ->
|
||||||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
||||||
@@ -736,7 +763,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
when_available(conn, fn -> stream_gate(conn, upstream, body, key, extra) end)
|
when_available(conn, fn -> stream_gate(conn, upstream, body, key, extra) end)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
when_available(conn, fn -> respond_gate(conn, VavPipeline.Gate.pass(upstream, body, key, rebuke: rebuke, extra_headers: extra)) end)
|
when_available(conn, fn -> respond_gate(conn, VavPipeline.Gate.pass(upstream, body, key, rebuke: rebuke, extra_headers: extra, key_id: caller_key_id(conn))) end)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
{:error, issue, correction} ->
|
{:error, issue, correction} ->
|
||||||
@@ -823,7 +850,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
end
|
end
|
||||||
}
|
}
|
||||||
|
|
||||||
case VavPipeline.Gate.Stream.pass(upstream, body, key, callbacks, extra_headers: extra) do
|
case VavPipeline.Gate.Stream.pass(upstream, body, key, callbacks, extra_headers: extra, key_id: caller_key_id(conn)) do
|
||||||
{:ok, %{status: 200, emitter: conn}} -> conn
|
{:ok, %{status: 200, emitter: conn}} -> conn
|
||||||
{:ok, %{status: status, reply: reply}} -> conn |> put_resp_header("x-bikoret-verdict", "none") |> send_json(status, reply)
|
{:ok, %{status: status, reply: reply}} -> conn |> put_resp_header("x-bikoret-verdict", "none") |> send_json(status, reply)
|
||||||
other -> respond_gate(conn, other)
|
other -> respond_gate(conn, other)
|
||||||
@@ -1076,6 +1103,58 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
# Internal — Sanhedrin processing
|
# Internal — Sanhedrin processing
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
# A digest of the caller's courtyard key — the ledger's owner column. Never the key.
|
||||||
|
defp caller_key_id(conn) do
|
||||||
|
case Gate.bearer(conn) do
|
||||||
|
{:ok, key} -> VavPipeline.Ledger.key_id(key)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_ledger(conn) do
|
||||||
|
if Process.whereis(VavPipeline.Repo) do
|
||||||
|
key_id = caller_key_id(conn)
|
||||||
|
limit = conn.params |> Map.get("limit", "50") |> Integer.parse() |> then(fn {n, _} -> n; :error -> 50 end)
|
||||||
|
before =
|
||||||
|
case Map.get(conn.params, "before") do
|
||||||
|
s when is_binary(s) -> (case DateTime.from_iso8601(s) do {:ok, t, _} -> t; _ -> nil end)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
records = VavPipeline.L3.Store.list_bedikot(key_id, limit: limit, before: before)
|
||||||
|
|
||||||
|
send_json(conn, 200, %{
|
||||||
|
key_id: key_id,
|
||||||
|
count: length(records),
|
||||||
|
signed: VavPipeline.Ledger.public_key() != nil,
|
||||||
|
public_key: "GET /peah/ledger_key",
|
||||||
|
entries: Enum.map(records, &VavPipeline.Ledger.render/1),
|
||||||
|
next: (case List.last(records) do nil -> nil; r -> "GET /courtyard/ledger?before=#{DateTime.to_iso8601(r.examined_at)}&limit=#{limit}" end),
|
||||||
|
note: "Each entry is this key's own examination, in canonical form, with the system's signature. The text examined is not kept; its SHA-256 and length are."
|
||||||
|
})
|
||||||
|
else
|
||||||
|
send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "The ledger lives in L3 and L3 is not reachable", correction: "Retry", resubmit: true})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_ledger_entry(conn, ref) do
|
||||||
|
if Process.whereis(VavPipeline.Repo) do
|
||||||
|
key_id = caller_key_id(conn)
|
||||||
|
|
||||||
|
case VavPipeline.L3.Store.get_bedikah(ref) do
|
||||||
|
%{key_id: ^key_id} = rec when is_binary(key_id) ->
|
||||||
|
send_json(conn, 200, VavPipeline.Ledger.render(rec))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# Not this key's, or not there: the same answer, so the ledger
|
||||||
|
# does not say whose a ref is.
|
||||||
|
send_error(conn, 404, "NOT_FOUND", %{issue: "No bedikah #{ref} in this key's ledger", correction: "Only examinations brought under this key are listed", resubmit: false})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "The ledger lives in L3 and L3 is not reachable", correction: "Retry", resubmit: true})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp process_sanhedrin(conn) do
|
defp process_sanhedrin(conn) do
|
||||||
params = conn.body_params
|
params = conn.body_params
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ defmodule VavPipeline.Doorpost do
|
|||||||
# changes; the update is a deliberate operator commit. A bare
|
# changes; the update is a deliberate operator commit. A bare
|
||||||
# hash-update commit (without a corresponding spec change in the
|
# hash-update commit (without a corresponding spec change in the
|
||||||
# same commit) is itself a drift signal.
|
# same commit) is itself a drift signal.
|
||||||
@expected_hash "66aa7874fae4875793791ce6ca7bbe55302109d5e4444bcb36d578dfe6e98a0d"
|
@expected_hash "02ddf313a41a885a0f4e91f07d586bf2bb6da7ab90ad64ef9b7743ba872aa333"
|
||||||
|
|
||||||
@doc "Return the list of files that constitute the category-grade specification."
|
@doc "Return the list of files that constitute the category-grade specification."
|
||||||
@spec files() :: [String.t()]
|
@spec files() :: [String.t()]
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ defmodule VavPipeline.L3.BedikahRecord do
|
|||||||
field :violations, {:array, :string}, default: []
|
field :violations, {:array, :string}, default: []
|
||||||
field :verdict, :string
|
field :verdict, :string
|
||||||
field :examined_at, :utc_datetime
|
field :examined_at, :utc_datetime
|
||||||
|
# The ledger: a digest of the courtyard key that brought the offering (VavPipeline.Ledger.key_id/1). Never the key.
|
||||||
|
field :key_id, :string
|
||||||
timestamps(type: :utc_datetime, updated_at: false)
|
timestamps(type: :utc_datetime, updated_at: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ defmodule VavPipeline.L3.BedikahRecord do
|
|||||||
# offering protocol extracted nothing, and that record must be kept.
|
# offering protocol extracted nothing, and that record must be kept.
|
||||||
# Ecto's cast turns "" into nil and validate_required calls "" blank;
|
# Ecto's cast turns "" into nil and validate_required calls "" blank;
|
||||||
# both are told otherwise here.
|
# both are told otherwise here.
|
||||||
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :rebukes, :mesorah, :coupling_trace, :violations]
|
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :rebukes, :mesorah, :coupling_trace, :violations, :key_id]
|
||||||
|
|
||||||
def changeset(record, attrs) do
|
def changeset(record, attrs) do
|
||||||
record
|
record
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ defmodule VavPipeline.L3.Store do
|
|||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "One bedikah by its ref, or nil."
|
||||||
|
def get_bedikah(ref) when is_binary(ref) do
|
||||||
|
Repo.get_by(VavPipeline.L3.BedikahRecord, bedikah_ref: ref)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The ledger of one courtyard key: its bedikot newest first, at most
|
||||||
|
`limit`, examined at or before `before` when given. A record without a
|
||||||
|
key_id (from before the ledger) belongs to no one and is not listed.
|
||||||
|
"""
|
||||||
|
def list_bedikot(key_id, opts \\ []) when is_binary(key_id) do
|
||||||
|
import Ecto.Query, only: [from: 2]
|
||||||
|
limit = opts |> Keyword.get(:limit, 50) |> min(200) |> max(1)
|
||||||
|
q = from b in VavPipeline.L3.BedikahRecord, where: b.key_id == ^key_id, order_by: [desc: b.examined_at, desc: b.id], limit: ^limit
|
||||||
|
q = case Keyword.get(opts, :before) do
|
||||||
|
%DateTime{} = t -> from b in q, where: b.examined_at <= ^t
|
||||||
|
_ -> q
|
||||||
|
end
|
||||||
|
Repo.all(q)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Record an examination of an external model's output (Construction §3.9).
|
Record an examination of an external model's output (Construction §3.9).
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
defmodule VavPipeline.Ledger do
|
||||||
|
@moduledoc """
|
||||||
|
The provenance ledger — a bedikah record as something its caller can
|
||||||
|
carry away and have checked by someone else.
|
||||||
|
|
||||||
|
Every bedikah is engraved in L3 with the output's hash and length, the
|
||||||
|
source digests, the coupling trace, the claims, the violations and the
|
||||||
|
verdict — never the text (Tamid §1.1). Until 2026-10-21 the caller had
|
||||||
|
no way back to it. Now a record is read back under the courtyard key
|
||||||
|
that brought the offering (`key_id`: a digest of the key, never the
|
||||||
|
key), rendered in one canonical form, and signed with the system's
|
||||||
|
Ed25519 key, whose public half anyone may fetch at `/peah/ledger_key`.
|
||||||
|
A regulator, a client or an audit can verify a record without asking
|
||||||
|
Bikoret: the canonical bytes, the signature, the public key.
|
||||||
|
|
||||||
|
Devarim 19:15 — a matter is established by witnesses. The ledger is
|
||||||
|
the witness that does not depend on the one who offered, and the
|
||||||
|
signature is what lets a third party trust the witness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc "A digest of a courtyard key, for the record. The key itself is never written."
|
||||||
|
@spec key_id(String.t() | nil) :: String.t() | nil
|
||||||
|
def key_id(nil), do: nil
|
||||||
|
|
||||||
|
def key_id(key) when is_binary(key),
|
||||||
|
do: :crypto.hash(:sha256, key) |> Base.encode16(case: :lower) |> binary_part(0, 32)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The record's fields that a verifier signs and checks — every field of
|
||||||
|
the examination, none of the database's own (id, inserted_at), and not
|
||||||
|
the key_id (whose key it was is the caller's business, not the record's).
|
||||||
|
"""
|
||||||
|
@ledger_fields ~w(bedikah_ref provenance query output_sha256 output_length vital_content propositions references authority_claims actions sources claims structure frames rebukes mesorah coupling_trace violations verdict examined_at)a
|
||||||
|
|
||||||
|
@spec ledger_fields() :: [atom()]
|
||||||
|
def ledger_fields, do: @ledger_fields
|
||||||
|
|
||||||
|
@doc "The record as a plain map of its ledger fields, dates in ISO 8601."
|
||||||
|
@spec entry(struct() | map()) :: map()
|
||||||
|
def entry(record) do
|
||||||
|
record
|
||||||
|
|> unstruct()
|
||||||
|
|> Map.take(@ledger_fields)
|
||||||
|
|> Map.new(fn
|
||||||
|
{k, %DateTime{} = dt} -> {k, DateTime.to_iso8601(dt)}
|
||||||
|
{k, v} -> {k, v}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp unstruct(%_{} = s), do: Map.from_struct(s)
|
||||||
|
defp unstruct(m), do: m
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The canonical bytes of an entry: JSON with every object's keys sorted,
|
||||||
|
no insignificant whitespace, UTF-8. Two implementations that agree on
|
||||||
|
this agree on the signature.
|
||||||
|
"""
|
||||||
|
@spec canonical(map()) :: binary()
|
||||||
|
def canonical(entry) when is_map(entry), do: entry |> sort_keys() |> Jason.encode!()
|
||||||
|
|
||||||
|
defp sort_keys(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||||
|
|
||||||
|
defp sort_keys(map) when is_map(map) and not is_struct(map) do
|
||||||
|
map
|
||||||
|
|> Enum.map(fn {k, v} -> {to_string(k), sort_keys(v)} end)
|
||||||
|
|> Enum.sort_by(fn {k, _} -> k end)
|
||||||
|
|> Jason.OrderedObject.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_keys(list) when is_list(list), do: Enum.map(list, &sort_keys/1)
|
||||||
|
defp sort_keys(other), do: other
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sign canonical bytes with the system's Ed25519 key. `nil` when no key
|
||||||
|
is configured — the record is still given, marked unsigned; nothing is
|
||||||
|
signed with a key made up on the spot.
|
||||||
|
"""
|
||||||
|
@spec sign(binary(), binary() | nil) :: binary() | nil
|
||||||
|
def sign(bytes, seed \\ seed())
|
||||||
|
def sign(_bytes, nil), do: nil
|
||||||
|
|
||||||
|
def sign(bytes, seed) when is_binary(seed) and byte_size(seed) == 32 do
|
||||||
|
:crypto.sign(:eddsa, :none, bytes, [seed, :ed25519]) |> Base.encode64()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Verify a base64 signature over canonical bytes with a base64 public key."
|
||||||
|
@spec verify(binary(), String.t(), String.t()) :: boolean()
|
||||||
|
def verify(bytes, signature_b64, public_key_b64) do
|
||||||
|
with {:ok, sig} <- Base.decode64(signature_b64),
|
||||||
|
{:ok, pub} <- Base.decode64(public_key_b64) do
|
||||||
|
:crypto.verify(:eddsa, :none, bytes, sig, [pub, :ed25519])
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "The public key (base64) a verifier uses, or nil when the ledger is unsigned."
|
||||||
|
@spec public_key(binary() | nil) :: String.t() | nil
|
||||||
|
def public_key(seed \\ seed())
|
||||||
|
def public_key(nil), do: nil
|
||||||
|
|
||||||
|
def public_key(seed) when is_binary(seed) and byte_size(seed) == 32 do
|
||||||
|
{pub, _priv} = :crypto.generate_key(:eddsa, :ed25519, seed)
|
||||||
|
Base.encode64(pub)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A record rendered for the caller: the entry, its canonical form's
|
||||||
|
SHA-256 (so the caller can confirm what was signed without re-encoding),
|
||||||
|
the signature, and how to verify.
|
||||||
|
"""
|
||||||
|
@spec render(struct() | map()) :: map()
|
||||||
|
def render(record) do
|
||||||
|
e = entry(record)
|
||||||
|
bytes = canonical(e)
|
||||||
|
sig = sign(bytes)
|
||||||
|
|
||||||
|
%{
|
||||||
|
entry: e,
|
||||||
|
canonical_sha256: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower),
|
||||||
|
signature: sig,
|
||||||
|
signed: sig != nil,
|
||||||
|
algorithm: "ed25519 over the canonical JSON of `entry` (keys sorted at every level, no whitespace, UTF-8)",
|
||||||
|
public_key: "GET /peah/ledger_key"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# The 32-byte seed from the environment (base64), or nil.
|
||||||
|
defp seed do
|
||||||
|
case Application.get_env(:vav_pipeline, :ledger_signing_key) do
|
||||||
|
nil -> nil
|
||||||
|
"" -> nil
|
||||||
|
b64 ->
|
||||||
|
case Base.decode64(String.trim(b64)) do
|
||||||
|
{:ok, s} when byte_size(s) == 32 -> s
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
defmodule VavPipeline.Repo.Migrations.BedikotKeyId do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
@moduledoc """
|
||||||
|
The ledger (2026-10-21): a bedikah remembers WHICH courtyard key brought
|
||||||
|
the offering — as a digest of the key, never the key — so a caller can
|
||||||
|
read back their own records and no one else's.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:bedikot) do
|
||||||
|
add :key_id, :string, null: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:bedikot, [:key_id, :examined_at])
|
||||||
|
create unique_index(:bedikot, [:bedikah_ref], name: :bedikot_bedikah_ref_unique)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
defmodule VavPipeline.LedgerL3Test do
|
||||||
|
@moduledoc "The ledger over L3: a key reads its own bedikot back, signed, and no one else's (2026-10-21)."
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Plug.Test
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
alias VavPipeline.BigdeiKodesh.Router
|
||||||
|
alias VavPipeline.L3.Store
|
||||||
|
|
||||||
|
@opts Router.init([])
|
||||||
|
@moduletag :l3
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, {:shared, self()})
|
||||||
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) end)
|
||||||
|
write_provision()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp write_provision do
|
||||||
|
VavPipeline.MannaLedger.reset()
|
||||||
|
on_exit(fn -> VavPipeline.MannaLedger.reset() end)
|
||||||
|
today = Date.utc_today() |> Date.to_iso8601()
|
||||||
|
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
|
||||||
|
provision = %{"date" => today, "provision" => %{"compute_units" => 50, "memory_mb" => 512, "api_calls_external" => 10, "storage_delta_mb" => 2, "sanhedrin_panels_max" => 2},
|
||||||
|
"type" => "regular", "expires_at" => expires, "written_by" => "ledger_l3_test", "written_at" => DateTime.utc_now() |> DateTime.to_iso8601()}
|
||||||
|
path = VavPipeline.Manna.provision_file()
|
||||||
|
File.mkdir_p!(Path.dirname(path))
|
||||||
|
File.write!(path, Jason.encode!(provision))
|
||||||
|
on_exit(fn -> File.rm(path) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp attrs(ref, key_id) do
|
||||||
|
%{
|
||||||
|
bedikah_ref: ref, provenance: "claude", query: "q", output_sha256: String.duplicate("cd", 32), output_length: 5,
|
||||||
|
vital_content: "", verdict: "empty", examined_at: DateTime.utc_now() |> DateTime.truncate(:second), key_id: key_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get(path, token) do
|
||||||
|
conn(:get, path) |> put_req_header("authorization", "Bearer #{token}") |> Router.call(@opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# An examination the offering protocol finds nothing in: the :empty branch, no engine needed.
|
||||||
|
defp empty_examination do
|
||||||
|
%{"provenance" => "claude", "query" => "status?", "vital_content" => "", "propositions" => [], "discarded" => ["Rolled out."],
|
||||||
|
"references" => [], "hebrew" => [], "roots" => [], "authority_claims" => [], "actions" => [],
|
||||||
|
"self_validation" => [], "self_promotion" => [], "excluded_adversarial" => [], "frames" => [], "sources" => [], "claims" => []}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the record keeps the key's digest, and the ledger lists only that key's records, newest first" do
|
||||||
|
mine = VavPipeline.Ledger.key_id("test-token")
|
||||||
|
other = VavPipeline.Ledger.key_id("someone-else")
|
||||||
|
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3aaaa0000000001", mine))
|
||||||
|
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3aaaa0000000002", mine))
|
||||||
|
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3bbbb0000000003", other))
|
||||||
|
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3cccc0000000004", nil))
|
||||||
|
|
||||||
|
refs = Store.list_bedikot(mine) |> Enum.map(& &1.bedikah_ref)
|
||||||
|
assert refs == ["bedikah-l3aaaa0000000002", "bedikah-l3aaaa0000000001"]
|
||||||
|
|
||||||
|
conn = get("/courtyard/ledger?limit=10", "test-token")
|
||||||
|
assert conn.status == 200
|
||||||
|
body = Jason.decode!(conn.resp_body)
|
||||||
|
assert body["key_id"] == mine
|
||||||
|
assert body["count"] == 2
|
||||||
|
assert Enum.map(body["entries"], & &1["entry"]["bedikah_ref"]) == ["bedikah-l3aaaa0000000002", "bedikah-l3aaaa0000000001"]
|
||||||
|
# never the text, never the key, never the owner column
|
||||||
|
e = hd(body["entries"])["entry"]
|
||||||
|
refute Map.has_key?(e, "key_id")
|
||||||
|
assert e["output_sha256"] == String.duplicate("cd", 32)
|
||||||
|
assert hd(body["entries"])["signed"] == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "one record by ref: mine answers, another key's and a missing one answer 404 alike" do
|
||||||
|
mine = VavPipeline.Ledger.key_id("test-token")
|
||||||
|
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3dddd0000000005", mine))
|
||||||
|
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3eeee0000000006", VavPipeline.Ledger.key_id("someone-else")))
|
||||||
|
|
||||||
|
assert get("/courtyard/bedikah/bedikah-l3dddd0000000005", "test-token").status == 200
|
||||||
|
assert get("/courtyard/bedikah/bedikah-l3eeee0000000006", "test-token").status == 404
|
||||||
|
assert get("/courtyard/bedikah/bedikah-l3ffff0000000007", "test-token").status == 404
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an examination is engraved under the caller's key and reads back signed and verifiable" do
|
||||||
|
Application.put_env(:vav_pipeline, :ledger_signing_key, Base.encode64(:crypto.strong_rand_bytes(32)))
|
||||||
|
mine = VavPipeline.Ledger.key_id("test-token")
|
||||||
|
|
||||||
|
try do
|
||||||
|
examiner = fn _o, _p, _q, _s -> {:ok, empty_examination()} end
|
||||||
|
assert {:ok, r} = VavPipeline.Bedikah.examine("Rolled out.", "claude", "status?", examiner: examiner, key_id: mine)
|
||||||
|
assert r.verdict == :empty
|
||||||
|
assert Store.get_bedikah(r.bedikah_ref).key_id == mine
|
||||||
|
|
||||||
|
back = get("/courtyard/bedikah/#{r.bedikah_ref}", "test-token")
|
||||||
|
assert back.status == 200
|
||||||
|
body = Jason.decode!(back.resp_body)
|
||||||
|
assert body["signed"] == true
|
||||||
|
pub = Jason.decode!(get("/peah/ledger_key", "x").resp_body)["public_key"]
|
||||||
|
canonical = VavPipeline.Ledger.canonical(body["entry"])
|
||||||
|
assert :crypto.hash(:sha256, canonical) |> Base.encode16(case: :lower) == body["canonical_sha256"]
|
||||||
|
assert VavPipeline.Ledger.verify(canonical, body["signature"], pub)
|
||||||
|
after
|
||||||
|
Application.delete_env(:vav_pipeline, :ledger_signing_key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
defmodule VavPipeline.LedgerTest do
|
||||||
|
@moduledoc "The provenance ledger: canonical form, signature, key digest (2026-10-21)."
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias VavPipeline.Ledger
|
||||||
|
|
||||||
|
@seed :crypto.strong_rand_bytes(32)
|
||||||
|
|
||||||
|
defp record do
|
||||||
|
%VavPipeline.L3.BedikahRecord{
|
||||||
|
id: 7,
|
||||||
|
bedikah_ref: "bedikah-0123456789abcdef",
|
||||||
|
provenance: "claude",
|
||||||
|
query: "q",
|
||||||
|
output_sha256: String.duplicate("ab", 32),
|
||||||
|
output_length: 12,
|
||||||
|
vital_content: "",
|
||||||
|
propositions: [],
|
||||||
|
references: ["Devarim 6:4"],
|
||||||
|
authority_claims: [],
|
||||||
|
actions: [],
|
||||||
|
sources: [%{"id" => "system", "sha256" => "00", "length" => 2}],
|
||||||
|
claims: [%{"index" => 1, "sentence" => "x", "anchors" => []}],
|
||||||
|
structure: %{"self_validation" => []},
|
||||||
|
frames: [],
|
||||||
|
rebukes: nil,
|
||||||
|
mesorah: [],
|
||||||
|
coupling_trace: [],
|
||||||
|
violations: [],
|
||||||
|
verdict: "tov",
|
||||||
|
examined_at: ~U[2026-10-21 12:00:00Z],
|
||||||
|
key_id: "deadbeef",
|
||||||
|
inserted_at: ~U[2026-10-21 12:00:01Z]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "key_id is a digest of the key, never the key, and nil for none" do
|
||||||
|
assert Ledger.key_id(nil) == nil
|
||||||
|
id = Ledger.key_id("courtyard-secret-key")
|
||||||
|
assert String.length(id) == 32
|
||||||
|
refute id =~ "courtyard"
|
||||||
|
assert id == Ledger.key_id("courtyard-secret-key")
|
||||||
|
refute id == Ledger.key_id("another")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the entry carries the examination's fields and neither the database's nor the owner's" do
|
||||||
|
e = Ledger.entry(record())
|
||||||
|
assert Map.keys(e) |> Enum.sort() == Enum.sort(Ledger.ledger_fields())
|
||||||
|
refute Map.has_key?(e, :id)
|
||||||
|
refute Map.has_key?(e, :key_id)
|
||||||
|
refute Map.has_key?(e, :inserted_at)
|
||||||
|
assert e.examined_at == "2026-10-21T12:00:00Z"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "canonical form sorts keys at every level and carries no whitespace" do
|
||||||
|
a = Ledger.canonical(%{"b" => 1, "a" => %{"z" => [%{"y" => 2, "x" => 1}], "m" => "s"}})
|
||||||
|
assert a == ~s({"a":{"m":"s","z":[{"x":1,"y":2}]},"b":1})
|
||||||
|
# the same map with keys given in another order canonicalizes the same
|
||||||
|
assert a == Ledger.canonical(%{"a" => %{"m" => "s", "z" => [%{"x" => 1, "y" => 2}]}, "b" => 1})
|
||||||
|
# atom and string keys meet in the same place
|
||||||
|
assert Ledger.canonical(%{b: 1, a: 2}) == ~s({"a":2,"b":1})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a signature verifies with the public key and fails on one changed byte" do
|
||||||
|
bytes = Ledger.canonical(Ledger.entry(record()))
|
||||||
|
sig = Ledger.sign(bytes, @seed)
|
||||||
|
pub = Ledger.public_key(@seed)
|
||||||
|
assert Ledger.verify(bytes, sig, pub)
|
||||||
|
refute Ledger.verify(bytes <> " ", sig, pub)
|
||||||
|
refute Ledger.verify(bytes, sig, Ledger.public_key(:crypto.strong_rand_bytes(32)))
|
||||||
|
refute Ledger.verify(bytes, "not base64!", pub)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without a seed nothing is signed, and the rendering says so" do
|
||||||
|
assert Ledger.sign("x", nil) == nil
|
||||||
|
assert Ledger.public_key(nil) == nil
|
||||||
|
r = Ledger.render(record())
|
||||||
|
assert r.entry.bedikah_ref == "bedikah-0123456789abcdef"
|
||||||
|
assert String.length(r.canonical_sha256) == 64
|
||||||
|
# the test env has no signing key configured
|
||||||
|
assert r.signed == false and r.signature == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a rendering with a seed configured is signed and self-consistent" do
|
||||||
|
Application.put_env(:vav_pipeline, :ledger_signing_key, Base.encode64(@seed))
|
||||||
|
|
||||||
|
try do
|
||||||
|
r = Ledger.render(record())
|
||||||
|
assert r.signed
|
||||||
|
bytes = Ledger.canonical(r.entry)
|
||||||
|
assert :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) == r.canonical_sha256
|
||||||
|
assert Ledger.verify(bytes, r.signature, Ledger.public_key())
|
||||||
|
after
|
||||||
|
Application.delete_env(:vav_pipeline, :ledger_signing_key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user