shen.typecheck triple-read fix + proof-carrying requests example#33
Conversation
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>
|
Pushed the second act: dynamic facts + revocation (PCR-Live). Facts are no longer axioms — one side-condition rule discharges self-describing fact leaves ( 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 |
|
Review findings from local review:
In 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.
In Please propagate write failures from Also noted: PR 33 is currently marked conflicting against Verification run locally:
|
# Conflicts: # .gitignore
…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>
|
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 ( 2. Silent fact-store write failures. 3. Merge conflict: merged Verification: selftest green under both engines with byte-identical verdicts and inference counts (the parity leg), |
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>
|
Addressed the two findings from my own detailed re-review (5eefbf8), each with a regression test: A — 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 Minor: the inference-count header is gated behind Verification: selftest green under both engines, byte-identical verdicts and inference counts; |
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>
Summary
This PR does two related things:
shen.typecheckso host code can correctly check an expression against compound Shen type syntax like(may alice read doc1).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.typecheckFixThe old helper read the expression and type independently. That breaks for 3+-ary user datatype types because
read-from-stringcooks expression position differently: standalone(may alice read doc1)is read as curried application syntax, so checks against that type silently returnedfalse.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 resetsshen.*infs*per call soshen.*maxinferences*is a per-check budget.Proof-Carrying Requests
examples/pcr/demonstrates runtime authorization as proof checking:X-Proof.(may subject action resource)from the request.[fact owns alice doc1]are discharged through a typed Lua bridge into a versioned live fact store.lua_shared_dictfact blob.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:
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.ngx.shared.DICT:setfailures throughgrant/revokeand 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/mainand resolved the.gitignoreoverlap by keeping the generalizedexamples/*/logs/and nginx temp-dir patterns.Verification
luajit examples/pcr/selftest.luaSHEN_TYPECHECK_NATIVE=off luajit examples/pcr/selftest.lualuajit test/typecheck_api_spec.luamake test->471 pass, 0 failPR branch is currently mergeable.