Files
blueandhanzo-dev 92b3bf64bb
image / test (push) Successful in 5m36s
image / build (push) Successful in 1m3s
consent: the answer belongs to the person it is about
The endpoint was self-scoped and fail-closed, but the record it guarded is a
property on the user row, and other writers reach that row.

ONE WRITER. users.Update is a full-row write any org admin may perform on any
member, and its server-owned carry-forward list did not include the consent
record — so one request both FORGED an answer (by sending one) and DESTROYED a
real one (by sending a body with no properties, which is what a partial client
sends), silently and unaudited. It now carries the stored record, and only that
record: every other property still comes from the body, so the console's admin
properties editor keeps working. users.Create dropped nothing, so provisioning
an account could pre-grant training permission in the new member's name; a
create body's consent is now discarded the way a body's credential already was.
The one caller entitled to state an answer at create time is the signup screen,
where the person answers for themselves, and it says so through a seam that is
off the wire. update-preferences shallow-merged any key including this one, an
unvalidated and unaudited second writer of the record that most needs a single
one; it now refuses the key and says where to answer instead.

AN ANSWER YOU DID NOT SEND IS NOT AN ANSWER YOU CHANGED. The wire shape took a
plain bool and a plain string, so a screen saving one switch silently revoked
the other — {"training":"granted"} also said insights=false. Both fields are
pointers now and the record merges field-wise under the row lock, so absent
means untouched. The published description said it merged; now it does.

EVIDENCE. The audit row is what makes a grant demonstrable, and it was
best-effort: written after the fact, dropped on error, and only for a change to
the training answer. It now covers the whole record, commits on the SAME
transaction as the answer, and fails the request if it cannot be written — a
consent we cannot evidence is worth less than one we never claimed. Its action
is reserved, so the generic audit CRUD can no longer mint a grant nobody gave or
delete the row recording a refusal. The ingress address is dropped: behind
hanzoai/ingress it identified our own pod while still being personal data we
would owe a retention answer for.

The write half also refused nothing, so a value the read half normalizes away
could be stored for a later reader to guess at. Encoding an answer now validates
it, in the one place a Consent becomes bytes.

scripts/mutate.py lands the strict runner beside the table it scores: a mutant
counts as killed only if the anchor is unique, the tree builds, the named test
matches and then fails. The table grows 11 -> 20 rows covering the new guards.
20/20 killed. make test green, race-clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 23:45:43 -07:00
..