Skip to content

shen.typecheck triple-read fix + proof-carrying requests example#33

Merged
pyrex41 merged 7 commits into
mainfrom
examples/proof-carrying-requests
Jul 6, 2026
Merged

shen.typecheck triple-read fix + proof-carrying requests example#33
pyrex41 merged 7 commits into
mainfrom
examples/proof-carrying-requests

Conversation

@pyrex41

@pyrex41 pyrex41 commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

This PR does two related things:

  • Fixes the embedding helper shen.typecheck so host code can correctly check an expression against compound Shen type syntax like (may alice read doc1).
  • Adds examples/pcr/, a proof-carrying-request gateway: clients present proof terms, OpenResty checks them against (may subject action resource), and live facts are consulted at proof-check time.

shen.typecheck Fix

The old helper read the expression and type independently. That breaks for 3+-ary user datatype types because read-from-string cooks expression position differently: standalone (may alice read doc1) is read as curried application syntax, so checks against that type silently returned false.

The helper now reads "EXPR : TYPE" as one triple, matching the shape Shen's loader consumes, validates that exactly one expression and one type were provided, and still resets shen.*infs* per call so shen.*maxinferences* is a per-check budget.

Proof-Carrying Requests

examples/pcr/ demonstrates runtime authorization as proof checking:

  • A request carries X-Proof.
  • The gate constructs (may subject action resource) from the request.
  • Shen checks the presented proof term; it never searches for one.
  • Fact leaves like [fact owns alice doc1] are discharged through a typed Lua bridge into a versioned live fact store.
  • Grant/revoke changes affect the same proof bytes on the next check, across workers sharing the lua_shared_dict fact blob.
  • Allowed requests log the proof, fact-store version, inference count, and consumed fact leaves as an audit trail.

The example covers owner/member/delegation proofs, TTL facts, replica-mode staleness denial, and a bounded hostile-input posture.

Review Fixes Included

Two review findings are fixed in the latest branch commit:

  • External atoms are now lowercase-starting only, so admitted S/A/R-style atoms cannot be read as Shen type variables in the constructed judgment. Regression tests first admit uppercase atoms into the fact world and then assert subject/action/resource/proof-token paths still deny.
  • Fact-store writes now propagate ngx.shared.DICT:set failures through grant/revoke and the admin endpoints. A failed write returns non-200 JSON and leaves the previous fact world in force; tests simulate failed revoke/grant and recovery.

The branch also merged origin/main and resolved the .gitignore overlap by keeping the generalized examples/*/logs/ and nginx temp-dir patterns.

Verification

  • luajit examples/pcr/selftest.lua
  • SHEN_TYPECHECK_NATIVE=off luajit examples/pcr/selftest.lua
  • luajit test/typecheck_api_spec.lua
  • make test -> 471 pass, 0 fail

PR branch is currently mergeable.

Reuben Brooks and others added 3 commits July 6, 2026 11:15
The embed helper read the type standalone, but read-from-string cooks
expression and type positions differently: in expression position a
compound form of three or more elements is curried into application
syntax — (may alice read doc1) becomes ((((fn may) alice) read) doc1) —
so any check against a 3+-ary user-datatype type silently returned
false, in both engine modes. Only the form after : is kept as raw type
syntax, so the helper now reads "EXPR : TYPE" together, the same triple
shape (load)'s work-through consumes, and validates it is exactly three
forms with : in the middle (which also fail-closes attempts to smuggle
extra forms through either string).

Simple (list X) types never tripped this — currying starts at two
arguments — which is how the existing specs missed it. The regression
tests use the examples/policy/policy_proof.shen authorization encoding,
where the 4-ary (may S A R) is exactly the shape that broke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runtime-typechecking counterpart to the policy example's compiled
decision tier: the client attaches a proof term (X-Proof) and the
OpenResty gate CHECKS it against the judgment (may subject action
resource) built from the request — it never searches. Checking a given
term is bounded by its size (the demo's deepest proof: 50 inferences,
~400 us warm, ~2.5k checks/sec/core), so the expensive open-ended
direction never runs at request time, and every allowed request is
logged with the proof that justified it.

A delegation rule shows why proofs beat booleans and bearer scopes:
carol's authority is a nested term carrying the whole justification
chain (alice delegated AND alice owns AND alice is in the tenant),
re-verified on every use — and a chain built on the wrong subterm
fails to connect.

The proof is treated as hostile input, each defense a selftest case:
judgment atoms pass a bare-symbol whitelist so request data cannot
smuggle syntax into the type; the shen.typecheck triple shape blocks
smuggling a different judgment through the proof string; reader errors,
oversized terms and over-budget terms (per-check *maxinferences*,
counter reset per call) all fail closed. Proofs are read, never
evaluated.

Selftest passes under both engine modes (native and
SHEN_TYPECHECK_NATIVE=off); exercised end-to-end through openresty.
Depends on the shen.typecheck triple-read fix — 3+-ary compound types
like (may S A R) were unreadable through the embed helper before it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Facts stop being axioms. One side-condition rule replaces them all —
if (pcr.fact? Pred S R) ... [fact Pred S R] : (Pred S R) — discharged
against a versioned live fact store (facts.lua) at proof-check time
through the typed lua.function bridge. Granting a fact makes proofs
start checking; revoking it makes the SAME proof bytes stop checking on
the very next request, on every worker: neither engine memoizes answers
(the only caches hold translated clause code), so revocation has zero
staleness at the checker. The fact leaf carries its ground triple
because a side condition can only check, never bind — a bare-token leaf
breaks under by-delegation, where S is not in the final judgment.

The store is ONE atomically-written {version, synced_at, facts} blob in
a lua_shared_dict (pure-Lua cell under plain luajit): one blob = one
epoch, so a check can never mix two fact worlds, an evicted or
undecodable blob is a deny, and freshness can never be stamped by a
failed sync. Authoritative mode (demo: /admin/grant + /admin/revoke)
has structurally zero staleness; replica mode (periodic pull stub of
period W) hard-caps the window at 3W by denying everything when the
sync ages out — outages degrade to denial, never frozen grants. Fact
values may be numbers: absolute expiry checked against the live clock,
i.e. time-limited grants that need no revoke call. The audit line
gains the fact-world version and the exact fact leaves consumed;
archiving {version, facts} per bump makes any logged triple
offline-replayable. New Enemy variant 1 is bounded by W; variant 2 is
documented with the zookie-style min-version extension as the named
path, not built.

Hostile-input posture: nothing reaches the reader un-vetted (the
symbol table is permanent, ~194 B/symbol) — subject/action/resource
and every proof token must be static vocabulary or an atom of a seen
fact world, monotone so revocation denies at the type level with the
honest reason, with a bounded distinct-atom budget as backstop
(selftest: 10k distinct hostile atoms, zero heap growth). The guard
allowlists fact predicates (a leaf can never assert (may ...)),
rejects "/" (store-key forgery) and non-strings (unbound pvars), and
a throwing store denies without poisoning the next check.

Engine parity is a tested invariant: side conditions MUST use the
typed bridge (a raw P.F guard passed native but failed CPS for a
byte-identical rule), and the selftest parity leg asserts identical
verdicts AND inference counts under SHEN_TYPECHECK_NATIVE=off.
Datatype reloading as the fact substrate was disqualified by
measurement (hundreds of ms + permanent leak per reload, compiler
wall near 200 axioms): the datatype compiles once; the store changes.

Verified: selftest green in both engine modes with byte-identical
deterministic output; make test 471/471; make certify 100%; live
through openresty with worker_processes 2 — 30 concurrent requests
split across both workers all 403 on the first request after one
revoke, and the boot path (bridge -> tc+ load -> fasl record/replay)
probed identical across {native,CPS} x {fasl cold,warm}. ~517 us/check
warm for the delegation proof (50 inferences, cheaper than the 69 the
static axioms cost).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pyrex41

pyrex41 commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Pushed the second act: dynamic facts + revocation (PCR-Live).

Facts are no longer axioms — one side-condition rule discharges self-describing fact leaves ([fact owns alice doc1]) against a versioned live store at proof-check time. Revoke a fact and the identical proof bytes 403 on the next request on every worker (verified: 30 concurrent requests across 2 workers, zero grace); alice's unrelated proof keeps passing. TTL facts expire with no revoke call. Replica mode hard-caps staleness at 3W by failing closed. The audit line now names the fact-world version and the exact leaves consumed, making logged decisions offline-replayable.

Design chosen by an ultracode probe/design/critique workflow: live side-condition queries beat epoch datatype reloading (measured compiler wall + permanent per-reload leak) and HMAC fact tokens (every gate becomes a minting oracle). Key empirical facts: neither engine memoizes derivations (revocation is zero-staleness at the checker), side-condition rules translate natively with CPS-identical verdicts+infs, and the typed lua.function bridge is mandatory for that parity. Selftest covers the revocation window, staleness cap, predicate-allowlist, intern-DoS (10k hostile atoms, zero heap growth), and an engine-parity leg with byte-identical output.

@pyrex41

pyrex41 commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Review findings from local review:

  1. Blocking: admitted uppercase atoms still turn exact judgments into variables.

In examples/pcr/app.lua, atom_ok permits uppercase identifiers, and if those atoms are admitted through the fact world, Shen reads S, A, R, etc. as type variables in the constructed (may subject action resource) judgment. I reproduced this on the PR branch by granting uppercase atoms, after which Alice's proof authorized S write doc1, alice write R, and alice A doc1.

External atoms need to be encoded or restricted so they cannot parse as Shen variables, with regression tests for admitted uppercase subject/action/resource atoms.

  1. Blocking: fact-store writes can silently fail while admin grant/revoke reports success.

In examples/pcr/facts.lua, ngx.shared.DICT:set returns failure information, but write() ignores the result and still returns the bumped version. If a revoke write fails under shared-dict pressure, the old blob remains and the proof can continue authorizing even though /admin/revoke returned {ok=true}.

Please propagate write failures from blob_set/write through grant, revoke, and start_sync_timer, and return a non-200 admin response when persistence fails.

Also noted: PR 33 is currently marked conflicting against main; git merge-tree HEAD origin/main shows the conflict in .gitignore.

Verification run locally:

  • luajit examples/pcr/selftest.lua
  • SHEN_TYPECHECK_NATIVE=off luajit examples/pcr/selftest.lua
  • luajit test/typecheck_api_spec.lua
  • make test (471 pass, 0 fail)

Reuben Brooks and others added 2 commits July 6, 2026 14:43
…ailures

Two blocking findings from review, both with regression tests:

1. Uppercase atoms parse as Shen TYPE VARIABLES: an uppercase subject/
   action/resource admitted through the fact world turned the judgment
   into a wildcard — (may S write doc1) is universally quantified, so
   alice's proof authorized it. Atoms are now lowercase-leading
   (^[a-z][a-z0-9-._]*$) at all three layers: the request gate, the
   proof-token admit, and the factq guard itself, so an uppercase atom
   cannot enter the judgment, the proof, or a store lookup even if it
   is smuggled into the fact world directly. Tests grant uppercase
   atoms into the store and assert every path still denies.

2. Fact-store writes could fail silently: blob_set ignored the
   ngx.shared set() result, so a failed revoke under shared-dict
   pressure reported {ok:true} while the old blob kept authorizing.
   Write failures now propagate through blob_set -> write -> grant/
   revoke/seed and the sync timer (logged), and the admin endpoints
   return 507 with the reason. Tests simulate write failure and assert:
   failed revoke keeps the grant effective AND reports failure, failed
   grant stays absent, the fact version never advances on a failed
   write, and both paths recover.

Also merged origin/main (new openresty-authz example) resolving the
.gitignore conflict in favor of the generalized examples/*/logs/ +
nginx temp-dir patterns, which subsume main's per-example entries.

Selftest green under both engines with byte-identical verdicts and
inference counts; make test 471/471.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pyrex41

pyrex41 commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Both blocking findings fixed in 80f4e3c, each with regression tests, plus the merge conflict resolved:

1. Uppercase atoms → type variables (the wildcard-judgment exploit). Atoms are now lowercase-leading (^[a-z][a-z0-9-._]*$) at all three layers — the request gate, the proof-token admit, and the factq guard itself — so an uppercase atom cannot reach the judgment, the proof, or a store lookup even when smuggled directly into the fact world. Regression tests grant S/A/R into the store via facts.grant (bypassing the app layer, reproducing your repro) and assert uppercase subject, action, resource, and proof-atom paths all deny.

2. Silent fact-store write failures. blob_set now propagates the ngx.shared:set result through write → grant/revoke/seed and the sync timer; admin endpoints return 507 with the reason on persistence failure. Tests simulate write failure and assert the failed revoke keeps the grant effective and reports failure, the failed grant stays absent, the fact version never advances on a failed write, and both recover.

3. Merge conflict: merged origin/main (welcoming the new openresty-authz example); .gitignore resolved in favor of the generalized examples/*/logs/ + nginx temp-dir patterns, which subsume the per-example entries.

Verification: selftest green under both engines with byte-identical verdicts and inference counts (the parity leg), make test 471/471.

A. mutate() no longer heals an undecodable blob into a fresh 1-fact world.
   decode_blob distinguishes an ABSENT blob (first write, version 1) from a
   PRESENT-but-undecodable one; the latter is refused and surfaced as a 507
   at the admin API, matching snapshot()'s existing deny-on-undecodable. A
   corrupt blob could otherwise wipe every fact and rewind the monotonic
   version the audit trail depends on. Regression: corrupt the blob, assert
   reads deny AND a grant refuses (507) rather than resetting.

B. admit() is now stateless: atom_ok and (static vocab or in the current
   fact world). The old per-worker monotone seen-set capped at 4096 never
   bounded the attacker (fact-world membership already does — attacker atoms
   are rejected before read-from-string) but DID false-deny legitimate atoms
   once a worker had handled >4096 distinct principals, a fail-closed
   availability bug at scale. A principal with no current facts now denies at
   the gate; to keep the honest type-layer reason for the demo, carol is
   seeded a benign same-tenant membership so revoking her delegation denies
   with "proof does not establish", not "unknown subject". Regression:
   expect_reason asserts the type-layer reason survives revocation.

Minor: the inference-count response header (X-Proof-Checked) is internal
detail, now gated behind PCR_DEBUG_HEADERS (env directive added to
nginx.conf so it works under OpenResty); X-Facts-Version stays (it is the
consistency token clients legitimately use). The write-failure test seam is
consolidated under facts._test alongside new corrupt_blob/clear injectors,
clearly marked not-public-API.

Selftest green under both engines with byte-identical verdicts and infs;
make test 471/471; live demo unchanged (honest revocation reason preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pyrex41

pyrex41 commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Addressed the two findings from my own detailed re-review (5eefbf8), each with a regression test:

A — mutate could silently reset the fact world on a corrupt blob. snapshot() denies reads on an undecodable blob, but mutate fell back to an empty {version=0} and rewrote version 1 — wiping every fact and rewinding the monotonic version the audit trail depends on. Now decode_blob distinguishes an absent blob (fine — first write) from a present-but-undecodable one (refused, surfaced as 507 at the admin API). Test corrupts the blob and asserts reads deny and a grant refuses rather than resetting.

B — the intern-DoS budget conflated hostile and legitimate atoms. The per-worker monotone seen-set capped at 4096 never bounded the attacker (fact-world membership already does — novel atoms are rejected before read-from-string) but false-denied legitimate atoms once a worker had handled >4096 distinct principals. admit is now statelessatom_ok and (static or in the current fact world). A principal with no current facts denies at the gate; to keep the honest type-layer reason for the demo's money shot, carol is seeded a benign same-tenant membership, so revoking her delegation denies with "proof does not establish" (not "unknown subject"). Regression asserts that reason survives revocation.

Minor: the inference-count header is gated behind PCR_DEBUG_HEADERS (with an env directive so it works under OpenResty); X-Facts-Version stays as the legitimate consistency token. The write-failure test hook is consolidated under facts._test with the new corrupt-blob/clear injectors, clearly not-public-API.

Verification: selftest green under both engines, byte-identical verdicts and inference counts; make test 471/471; live demo unchanged (the honest revocation reason is preserved). Both findings also written up in the Garmr implementation note as productionization traps (the LRU-grace-set caveat for B, the read/write-must-agree invariant for A).

strong-ideas.md is the vision doc behind this line of work — one typed
Shen core projected into every host that owns a boundary (edge, native,
ops, browser). The pcr example is its first concrete slice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pyrex41 pyrex41 merged commit 4fafcd0 into main Jul 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant