fix(six): send the body /v1/authz/check reads, and stop stamping instants nobody set

policy.check was built from the route's OpenAPI description, which is stale.
The handler cloud pins — hanzoai/authz v1.10.37, serve/use.go — binds
{subject, verb, path} and answers {allow, subject, verb, path}; a body of
{sub, obj, act} gets 400 "subject, verb, path required". go-sdk and js-sdk both
send the served spelling. The caller-facing surface is unchanged: check(sub,
act, obj) -> Decision{allow, sub, act, obj, reason}.

graph left an unset instant on the body as "". Cloud reads an absent `seen` as
`at` and an absent `as_of` as now, but reads "" as a timestamp it refuses:
POST /v1/graph/ingest with at:"" answers 400 timestamp "" is not RFC 3339. An
instant nobody set is now left off.

kb.Edge takes PEP 8's keyword escape, from_ and to, rather than renaming the
pair to source and target — the word is `from`, which is what the other two
spell it. Document names and connector providers are escaped into their path
segment, as they already are in Go and TypeScript.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-10 15:28:12 -07:00
co-authored by Hanzo Dev
parent 7824191215
commit 0a8e3272a0
4 changed files with 62 additions and 37 deletions
+22 -8
View File
@@ -48,18 +48,22 @@ class Fact:
confidence: float = 0.0
def write(self) -> Dict[str, Any]:
"""This fact as cloud reads it."""
return {
"""This fact as cloud reads it.
An instant nobody set is left out rather than sent as ``""``: cloud
reads an absent `seen` as `at`, and an empty string as a timestamp that
is not RFC 3339.
"""
out: Dict[str, Any] = {
"entity": self.entity,
"relation": self.relation,
"value": self.value,
"names": self.names,
"at": wire.stamp(self.at),
"seen": wire.stamp(self.seen),
"source": self.source,
"evidence": self.evidence,
"confidence": self.confidence,
}
return {**out, **_when(at=self.at, seen=self.seen)}
@classmethod
def read(cls, body: Any) -> "Fact":
@@ -260,12 +264,12 @@ class Graph:
return tuple(Fact.read(f) for f in wire.rows(body, "assertions"))
def resolve(self, entity: str, relation: str, at: Optional[datetime] = None) -> Resolution:
"""Which claim about `entity`'s `relation` wins, as of `at`."""
"""Which claim about `entity`'s `relation` wins, as of `at`. No `at` asks about now."""
return Resolution.read(
self.client.read(
"POST",
"/v1/graph/resolve",
body={"entity": entity, "relation": relation, "as_of": wire.stamp(at)},
body={"entity": entity, "relation": relation, **_when(as_of=at)},
)
)
@@ -291,7 +295,7 @@ class Graph:
"relation": relation,
"direction": direction,
"depth": depth,
"as_of": wire.stamp(at),
**_when(as_of=at),
},
)
)
@@ -327,4 +331,14 @@ class Graph:
def _source(text: str, source: str, subject: str, at: Optional[datetime]) -> Dict[str, Any]:
"""The body both readers of a document take."""
return {"text": text, "source": source, "subject": subject, "at": wire.stamp(at)}
return {"text": text, "source": source, "subject": subject, **_when(at=at)}
def _when(**instants: Optional[datetime]) -> Dict[str, str]:
"""The instants that were set, RFC 3339.
An instant nobody set is left off the body. Cloud reads an absent one as its
own default — now, or `at` for a `seen` — and an empty string as a timestamp
it refuses.
"""
return {name: wire.stamp(t) for name, t in instants.items() if t is not None}
+21 -10
View File
@@ -17,6 +17,7 @@ import json
from typing import TYPE_CHECKING, Any, Dict, Tuple, Optional
from datetime import datetime
from dataclasses import dataclass
from urllib.parse import quote
from hanzoai import answer
from hanzoai.page import Page
@@ -186,15 +187,20 @@ class Node:
@dataclass(frozen=True)
class Edge:
"""A link between two documents: a parent, a wikilink, a provenance edge."""
"""A link between two documents: a parent, a wikilink, a provenance edge.
source: str = ""
target: str = ""
The trailing underscore on `from_` is PEP 8's escape for a keyword, the same
one :meth:`hanzoai.Client.as_` and :meth:`hanzoai.graph.Graph.assert_` take.
The word is still ``from``, which is what Go and TypeScript spell it.
"""
from_: str = ""
to: str = ""
kind: str = ""
@classmethod
def read(cls, body: Any) -> "Edge":
return cls(source=text(body, "from"), target=text(body, "to"), kind=text(body, "kind"))
return cls(from_=text(body, "from"), to=text(body, "to"), kind=text(body, "kind"))
@dataclass(frozen=True)
@@ -228,7 +234,7 @@ class Kb:
"""Write a document. A name that already exists is replaced; absent, one is created."""
path = _path(doc.kind)
reply: Reply = (
self.client.send("PUT", path + "/" + doc.name, body=doc.write())
self.client.send("PUT", path + "/" + _seg(doc.name), body=doc.write())
if doc.name
else self.client.send("POST", path, body=doc.write())
)
@@ -236,7 +242,7 @@ class Kb:
def get(self, kind: str, name: str) -> Doc:
"""One document by name."""
return Doc.read(self.client.read("GET", _path(kind) + "/" + name), kind)
return Doc.read(self.client.read("GET", _path(kind) + "/" + _seg(name)), kind)
def list(self, kind: str, *, project: Optional[str] = None, limit: Optional[int] = None) -> Page[Doc]:
"""The org's documents of one kind, newest-updated first.
@@ -254,7 +260,7 @@ class Kb:
def drop(self, kind: str, name: str) -> Answer[None]:
"""Remove one document."""
return answer.read(self.client.send("DELETE", _path(kind) + "/" + name), lambda _: None)
return answer.read(self.client.send("DELETE", _path(kind) + "/" + _seg(name)), lambda _: None)
def import_(self, export: bytes, *, format: str, project: Optional[str] = None) -> Answer[Import]:
"""File an exported vault as a tree of pages, links intact.
@@ -283,15 +289,15 @@ class Kb:
def connect(self, provider: str) -> Link:
"""Where to send a person to authorize `provider`."""
return Link.read(self.client.read("GET", "/v1/knowledge/connectors/" + provider + "/connect"))
return Link.read(self.client.read("GET", "/v1/knowledge/connectors/" + _seg(provider) + "/connect"))
def sync(self, provider: str) -> Answer[Sync]:
"""Pull what `provider` has now."""
return answer.read(self.client.send("POST", "/v1/knowledge/connectors/" + provider + "/sync"), Sync.read)
return answer.read(self.client.send("POST", "/v1/knowledge/connectors/" + _seg(provider) + "/sync"), Sync.read)
def revoke(self, provider: str) -> Answer[None]:
"""Disconnect `provider`."""
return answer.read(self.client.send("DELETE", "/v1/knowledge/connectors/" + provider), lambda _: None)
return answer.read(self.client.send("DELETE", "/v1/knowledge/connectors/" + _seg(provider)), lambda _: None)
def links(self) -> Links:
"""The corpus's own parent, wikilink and provenance edges."""
@@ -311,3 +317,8 @@ def _path(kind: str) -> str:
if kind not in KINDS:
raise ValueError("kind is one of {0}, not {1!r}".format(", ".join(KINDS), kind))
return "/v1/framework/kb." + kind
def _seg(name: str) -> str:
"""One path segment. A name with a slash in it addresses one document, not two."""
return quote(name, safe="")
+7 -6
View File
@@ -39,9 +39,9 @@ class Decision:
def read(cls, body: Any, sub: str, act: str, obj: str) -> "Decision":
return cls(
allow=flag(body, "allow"),
sub=text(body, "sub") or sub,
act=text(body, "act") or act,
obj=text(body, "obj") or obj,
sub=text(body, "subject") or sub,
act=text(body, "verb") or act,
obj=text(body, "path") or obj,
reason=text(body, "reason"),
)
@@ -56,12 +56,13 @@ class Policy:
"""Ask whether `sub` may `act` on `obj`. All three are required.
The arguments read in the order the sentence does — subject, verb,
object. Cloud's body orders its members ``{sub, obj, act}``; the request
is built that way here, once, so no caller has to hold both orders.
object. The route reads ``{subject, verb, path}`` and answers
``{allow, subject, verb, path}``; the two spellings meet here, once, so
no caller has to hold both.
Hand-written rather than generated: the route declares neither a request
body nor a response, so every generator emits a method that takes
nothing and returns nothing.
"""
body = self.client.read("POST", "/v1/authz/check", body={"sub": sub, "obj": obj, "act": act})
body = self.client.read("POST", "/v1/authz/check", body={"subject": sub, "verb": act, "path": obj})
return Decision.read(body, sub, act, obj)
+12 -13
View File
@@ -160,27 +160,27 @@ def test_spent_pages_the_charged_ledger():
# --------------------------------------------------------------------------
def test_check_takes_the_sentence_order_and_sends_cloud_s_member_order():
"""A person says subject, verb, object. Cloud's body says `{sub, obj, act}`.
def test_check_takes_the_sentence_order_and_sends_the_route_s_member_names():
"""A person says subject, verb, object. The route reads `{subject, verb, path}`.
The difference is spelled once, here, and nowhere a caller can see it.
"""
calls = Calls(ok({"allow": True, "sub": "usr_7", "obj": "graph:acme", "act": "write"}))
decision = Policy(calls).check("usr_7", "write", "graph:acme")
calls = Calls(ok({"allow": True, "subject": "usr_7", "path": "acme/graph", "verb": "write"}))
decision = Policy(calls).check("usr_7", "write", "acme/graph")
assert calls.asked_once.body == {"sub": "usr_7", "obj": "graph:acme", "act": "write"}
assert calls.asked_once.body == {"subject": "usr_7", "verb": "write", "path": "acme/graph"}
assert decision.allow is True
assert (decision.sub, decision.act, decision.obj) == ("usr_7", "write", "graph:acme")
assert (decision.sub, decision.act, decision.obj) == ("usr_7", "write", "acme/graph")
def test_a_denial_is_a_verdict_not_a_refusal():
"""Asking whether you may is a question with an answer. `allow` is False, and that is all."""
decision = Policy(Calls(ok({"allow": False, "reason": "no grant at or above graph:acme"}))).check(
"usr_7", "write", "graph:acme"
decision = Policy(Calls(ok({"allow": False, "reason": "no grant at or above acme/graph"}))).check(
"usr_7", "write", "acme/graph"
)
assert decision.allow is False
assert decision.reason == "no grant at or above graph:acme"
assert decision.obj == "graph:acme", "the question survives even when cloud echoes nothing"
assert decision.reason == "no grant at or above acme/graph"
assert decision.obj == "acme/graph", "the question survives even when cloud echoes nothing"
# --------------------------------------------------------------------------
@@ -470,7 +470,7 @@ def test_the_corpus_map_states_its_own_incompleteness():
assert links.partial is True
assert links.nodes[0].name == "runbook"
assert (links.edges[0].source, links.edges[0].target, links.edges[0].kind) == (
assert (links.edges[0].from_, links.edges[0].to, links.edges[0].kind) == (
"kb.page/runbook",
"kb.page/ops",
"parent",
@@ -619,8 +619,7 @@ def test_a_walk_follows_edges_in_the_direction_it_was_given():
"relation": "owns",
"direction": "both",
"depth": 2,
"as_of": "",
}
}, "an instant nobody set is left off the body, not sent as an empty timestamp"
assert walk.entities == ("acme", "acme-eu")