From 0d107105c67aa024f75da7bb21693b90a8b83521 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 11:11:08 -0400 Subject: [PATCH] =?UTF-8?q?jcp=20=C2=A74.10:=20the=20flow=20gate=20had=20n?= =?UTF-8?q?o=20spec=20section,=20and=20six=20code=20references=20cited=20i?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `jcp-runtime` cites "SPEC §4.10" in six places — the taint floor, the flow gate, check 6c — and jcp/SPEC.md had no §4.10. The section was never written. So the mechanism that decides whether a value's provenance may exercise a grant is implemented, gate-enforcing and tested, with no normative text behind it: the mirror of the defects this sweep has mostly been closing, a check with no claim rather than a claim with no check. I found it by nearly colliding with it — #189 had taken §4.10 for durable spend and is renumbered to §4.11, because the code had already spoken for the number. §4.10 documents what is there, and every normative sentence is backed by an existing test: - the label's three independent components, `⊥` and `⊤`; - the ceiling as a `FlowBelow` caveat, several composing to the strictest via `meet` on the label and `min` on the tainted sub-budget, so attenuation never widens one; - the taint floor as the enforcement point's own refusal, the flow analogue of a trust root, default-open; - the three-case table whose middle row is the point: provenance **absent** is not provenance **clean**, and a ceilinged grant fails closed on an unlabelled argument; - the placement, which is normative: after capability and signature, because a ceiling inside an unauthenticated grant would seal a verdict over attacker-chosen policy; before caveats and budget, so a tainted call costs zero joules. One sentence I had to pull back: I first wrote that the implementation propagates labels. It does not — `flow_gate` takes the facts from its caller, so propagation belongs to the host, which is the only party that sees the data move. The spec now says so, and says what JCP does guarantee instead: a host that propagates gets a decision bound to what it reported, and one that does not gets a refusal rather than a pass. Conformance class **C25** is declared and defined in §9's list. It cites only behaviour `jcp-runtime` already tests — zero-debit flow denial, failing closed without facts, the taint floor overriding a permissive ceiling, the tainted sub-budget, and byte-identical encoding without a flow caveat. **§4.3 gains a Propagation paragraph.** §4.3 binds an enforcement point *once it holds* a revocation and says nothing about how it comes to hold one. With a budget sharded across N points (§4.5), a revocation that reached three of five leaves the grant live at two — and an allow from a point that had not yet heard is indistinguishable, after the fact, from an allow by a point that heard and ignored it. The first is propagation delay; the second violates this spec. An implementation MUST now define how revocations reach its points and SHOULD let each state how current its knowledge is; `Runtime::revocation_known_through` records it, defaulting to `None`, which an auditor MUST read as *unverifiable* rather than current. Stated in the method's own docs rather than left implied: the marker is state on the enforcement point and the `jcp/2` receipt does not carry it, so an auditor needs it reported out of band. Binding it into the signed core is the stronger fix and reaches every `Decision` construction site across six standards' reference implementations — 75 of them, in workspaces that do not build together — so it is a separate piece of work rather than a line in this one. Falsifying the marker caught a vacuous assertion of my own. I had written that declaring a marker changes no decision, and checked it by comparing two runtimes that both held *no* revocation — which passes however the marker behaves. The test now applies a real revocation first, so clearing the revocation set from the setter is caught. Two earlier mutation attempts also "passed" because they patched lines that do not exist: `Runtime` derives `Default`, so the marker's `None` comes by derivation and there is no constructor literal to break. jcp-rs workspace: 404 passed. The two clippy warnings in jcp-runtime are pre-existing (2 on the baseline, checked against the stash). Co-Authored-By: Claude Opus 5 --- jcp/SPEC.md | 87 ++++++++++++++++++++++++ jcp/jcp-rs/crates/jcp-runtime/src/lib.rs | 83 ++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index b20e80943..883866a4d 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -172,6 +172,27 @@ decision against the revoked grant **or any chain containing it**: a delegation subtree dies with any of its ancestors. Both the direct and the chained decision path MUST enforce this. +**Propagation.** The rule above binds an enforcement point *once it holds the +revocation*, and says nothing about how it comes to hold one. §4.3 requires an enforcement point to deny a revoked grant **once it holds the +revocation**, and says nothing about how it comes to hold one. With a budget +sharded across N enforcement points (§4.5), a revocation that has reached three +of five leaves the grant live at two, and — the part that matters for an audit — +an allow issued by a point that *had not yet heard* is indistinguishable, after +the fact, from an allow by a point that heard and ignored it. The first is a +propagation delay. The second is a violation of this specification. + +An implementation that supports revocation **MUST** define how revocations reach +its enforcement points, and **SHOULD** let each point state how current its +revocation knowledge is, as a marker monotonic in whatever its revocation feed +counts — a sequence number, a log index, a timestamp. JCP neither defines nor +interprets the marker; it requires only that a larger value mean "has seen at +least as much". + +Given a revocation issued at marker `M`, an allow from a point at +`known_through < M` is explicable and one at `known_through >= M` is not. Where a +point states no marker, an auditor **MUST** treat its allows as *unverifiable* +for revocation rather than as compliant — "did not say" is not "was current". + ### 4.4 Delegation control (REQUIRED) A grant MAY carry `delegate_depth`: how many further attenuation hops it @@ -374,6 +395,71 @@ An empty caveat set is omitted from the canonical encoding, so a grant without caveats is byte-identical to one minted before they existed — its content id and signature are unchanged. Conformance class **C24**. +### 4.10 Flow: what a value's provenance may exercise (OPTIONAL) + +A grant answers *who* may do *what*, for *how many joules*. It does not answer +where the **argument** came from. An agent holding a legitimate grant, handed a +value that originated in an untrusted channel, exercises real authority on +attacker-chosen input — and every check above passes, because the grant is valid +and the action is granted. + +A value therefore carries a **label**, and a grant MAY carry a ceiling the label +must sit below. + +``` +Label { integrity: Integrity, origins: Origins, readers: Readers } +``` + +The three components order independently and the label order is their product. +`⊥` is a principal-authored value from no channel, readable by all — the label of +anything in a run that has touched no untrusted source. `⊤` is untrusted, from +anywhere, readable by nobody. + +**JCP consumes a label; it does not compute one.** Propagation — the label of a +result being at least the join of the labels of everything that went into it — is +the host's job, because only the host sees the data move. JCP's contribution is +the gate and, below, the rule that an *uncomputed* label is not a clean one. A +host that propagates badly will present a label JCP cannot audit; what JCP +guarantees is that a host which propagates at all gets a decision bound to what +it reported, and that one which does not gets a refusal rather than a pass. + +A grant expresses its ceiling as a `FlowBelow` caveat (§4.9) carrying a +`FlowCeiling`: the ceiling label, and optionally a `tainted_budget_uj` — a +separate, smaller joule allowance for calls whose argument is not `⊥`. Where a +chain carries several, they compose to the **strictest**; attenuation never +widens one. + +An enforcement point MAY also configure a **taint floor** per resource — the flow +analogue of a trust root (§7). The ceiling is what the *issuer* will permit; the +floor is what the *enforcement point* will accept regardless of who granted what. +Default-open, so an unconfigured point behaves exactly as before. + +**The gate.** A decision is denied unless the argument's label sits at or below +the grant's ceiling **and** the resource's taint floor does not refuse its +origins. Three cases are distinguished, and the distinction is the point: + +| facts | meaning | result | +|---|---|---| +| absent | provenance was **not computed** | **fails closed** where a ceiling is present; a ceiling-free grant is unaffected | +| `⊥` | computed, and clean | passes any ceiling | +| labelled | computed, and tainted | tested against the ceiling and the floor | + +"Not computed" **MUST NOT** be read as "clean". An un-migrated caller that cannot +say where its argument came from has not established that the argument is safe, +and a grant whose issuer took the trouble to set a ceiling MUST NOT be exercised +on an unlabelled value. + +**Where the check sits is normative.** It MUST run *after* capability and +signature verification — the ceiling lives inside an authenticated grant, so +judging flow before the signature would seal a verdict over attacker-chosen +policy — and *before* caveats and the budget test, so that **a tainted call costs +zero joules**. A denial on flow is the same invariant as a denial on capability: +nothing is debited and nothing runs. + +A grant carrying no `FlowBelow` caveat is byte-identical to one minted before +flow existed, and an enforcement point with no taint floor configured decides +exactly as it did before. Conformance class **C25**. + ## 5. Energy (REQUIRED) Every metered cost is integer microjoules with a provenance tag: `HwShunt` @@ -743,4 +829,5 @@ gate. - **C21 — checkpoints + amendments:** an agent checkpoints its own state; only the grant's issuer — a principal distinct from the agent — may amend, only to tighten; an agent steering itself, a non-issuer amending, a widening amendment, and an amendment bound to a different checkpoint are each refused. - **C23 — receipt binds its payload + profile migration:** a `jcp/2` receipt commits the `payload_hash` it decided over; substituting only the payload (same grant, action, and cost) fails verification — the forgery a payload-blind receipt could not detect; and receipts published under `jcp/1` still verify, because the verifier recomputes the preimage under the profile the receipt names rather than assuming one. - **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed. +- **C25 — flow:** a value's propagated label must sit at or below the grant's ceiling and clear the resource's taint floor; an argument whose provenance was **not computed** fails closed against a ceilinged grant rather than passing as clean; several ceilings in a chain compose to the strictest and attenuation never widens one; a flow denial debits nothing, so a tainted call costs zero joules; and a grant with no flow caveat encodes byte-identically to one minted before flow existed. - **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index 2dbf08560..924f9a54b 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -61,6 +61,11 @@ pub struct Runtime { /// another. spent_minor: HashMap, revocations: RevocationSet, + /// How current this point's revocation knowledge is (SPEC §4.3, + /// Propagation). `None` — the default — means it has not said, which an + /// auditor must read as *unverifiable*, not as current. Opaque and + /// monotonic in whatever the deployment's revocation feed counts. + revocation_marker: Option, /// Configured trust roots (SPEC §7): resource → the root-issuer keys this /// enforcement point honors for it. Empty for a resource = not configured /// (open); once any root is configured for a resource, a chain whose root @@ -302,6 +307,45 @@ impl Runtime { self.revocations.contains(grant_id) } + /// Declare how current this enforcement point's revocation knowledge is + /// (SPEC §4.3). + /// + /// The marker is opaque to JCP and monotonic in whatever the deployment's + /// revocation feed counts — a sequence number, a log index, a timestamp. + /// JCP neither defines nor interprets it; it only requires that a larger + /// value mean "has seen at least as much". + /// + /// Why it exists. §4.3 requires an enforcement point to deny a revoked + /// grant *once it has the revocation*, and says nothing about how it gets + /// one. With budgets sharded across N points (§4.5), a revocation that has + /// reached three of five leaves the grant live at two — and an allow from + /// a point that had not yet heard is indistinguishable, after the fact, + /// from an allow by a point that had heard and ignored it. The first is a + /// propagation delay; the second is a violation of this spec. Recording + /// the marker is what separates them: given a revocation issued at marker + /// M, an allow from a point at `known_through < M` is explicable, and one + /// at `>= M` is not. + /// + /// **Honest limit:** this is state on the enforcement point, and the + /// `jcp/2` receipt does not carry it. An auditor reconciling receipts + /// against a revocation feed therefore needs the point to report its marker + /// out of band. Putting it in the signed core would be the stronger + /// binding and is a wire change reaching every `Decision` construction site + /// across six standards' reference implementations, which do not build + /// together. + pub fn revocation_known_through(&mut self, marker: u64) { + self.revocation_marker = Some(marker); + } + + /// The marker last declared by [`Runtime::revocation_known_through`]. + /// + /// `None` means this point has not said how current its knowledge is. That + /// is not "fully current": an auditor MUST treat its allows as + /// unverifiable for revocation rather than as compliant (SPEC §4.3). + pub fn revocation_marker(&self) -> Option { + self.revocation_marker + } + /// Configure a trust root (SPEC §7): honor `issuer` as a root for /// `resource`. The same local, pairwise trust shape as `authorized_keys` — /// no certificate authority, no hierarchy. Once any root is configured for @@ -1114,6 +1158,45 @@ mod tests { assert_eq!(d.verdict, Verdict::Deny(DenyReason::BadSignature)); } + #[test] + fn an_unstated_revocation_marker_is_not_a_current_one() { + // SPEC §4.3, Propagation. The marker exists so an allow can be told + // apart from an allow by a point that had not yet heard. The default + // must therefore be "has not said" and not a number an auditor could + // read as current — `None`, never `Some(0)`, which would claim the + // point had seen the start of the feed. + let mut rt = Runtime::new(); + assert_eq!(rt.revocation_marker(), None, "a fresh point must not claim currency"); + rt.revocation_known_through(42); + assert_eq!(rt.revocation_marker(), Some(42)); + // Monotonic is the deployment's contract, not this crate's: JCP does + // not interpret the marker, so it does not police its direction either. + // Stated plainly because a reader could expect a clamp here. + rt.revocation_known_through(7); + assert_eq!(rt.revocation_marker(), Some(7), "JCP neither defines nor polices the feed's order"); + // And declaring one changes no decision: it is a statement about the + // point's knowledge, not an input to the gate. Checked against a + // runtime that actually holds a revocation — comparing two runtimes + // with empty revocation sets would pass however the marker behaved. + let (sg, holder, r) = setup(); + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + let sr = jcp_grant::revoke(&sk(1), &sg.grant, [7; 16]).unwrap(); + rt.apply_revocation(&sr, &sg.grant).unwrap(); + assert_eq!( + rt.decide(&f, &sg, holder, Energy::measured(1), 10).verdict, + Verdict::Deny(DenyReason::Revoked), + "premise: the grant is revoked at this point" + ); + rt.revocation_known_through(99); + assert_eq!( + rt.decide(&f, &sg, holder, Energy::measured(1), 10).verdict, + Verdict::Deny(DenyReason::Revoked), + "declaring a marker un-revoked a grant: the marker is not an input to the gate" + ); + assert!(rt.is_revoked(&sg.grant.id())); + } + #[test] fn revoked_grant_denied() { let (sg, holder, r) = setup(); -- 2.54.0 (Apple Git-157)