Subjectivity record notes: an ACL granularity problem
(was "Subjectivity owner-visibility (gate-one tightening)" — renamed: "owner" was the wrong frame. Nothing varies about owner visibility; the whole corpus is the owner's. What varies is whether a VISITOR can reach a record note, which is ordinary ACL.)
Verdict (2026-07-16): subjectivity is the same kind of thing as wiki/output — it goes through the ordinary corpus ACL (role globs). Its one extra axis is
show_as_source: whether the agent, having read it, may also cite it. Two knobs, not three:
knob lives on governs role glob role can the agent read it show_as_sourcenote having read it, may it cite it — defaults to false for subjectivity since 017c37d23(2026-09-05):corpus.create/corpus.updateused to inherit the wiki/output nil→true default (showAsSourceForSubjectivity,backend/internal/corpus/ops/corpus_write_args.go:89)The "owner tier" below is a second can-it-be-read knob beside the glob — the same axis, so it adds no capability. Built (
eacc06c7,36fced08), reverted (e9241618). The gap it identifies is real; the answer is glob granularity, not a new concept — and the per-code half of that answer shipped the same day, see "What to do instead".
Motivating event: subjectivity/ now holds its first record note — the CV: real name, education, employers, city. PII entered a corpus that StandMeet serves to visitors.
The two gates, as built (this part stands — an accurate description)
- Gate 1 — into context. Corpus admission is a role-level path-glob positive whitelist (
role_corpus_uris,MatchesAnyCorpusGlobinpath_acl.go): match-any,raw://**hardwired deny, empty set = deny all, frozen into the RoleSnapshot at issue. Access codes can only narrow their assumed role (pure-AND hierarchy,capability-acl-hierarchy.md); code-level corpus narrowing — deferred as decision A.2 when this was written — shipped on 2026-07-16 ascode_corpus_denials(6395374b0backend,9a08ab6a2GUI on role + code;backend/db/schema.sql:1043). - Gate 2 — shown to the visitor. Subjectivity is a private tier: read grounds the agent's voice but is never cited to visitors unless the note opts in with
show_as_source: true(server-authoritative per-note check,subjectivity_cite.go).
The gap (real, still open): gate 2 hides attribution, not information — an agent that read the CV can still state the employer in an answer. And one subjectivity://** grant admits record notes along with the stances.
The wrong conclusion drawn from it: "gate 1 needs a note-level exclusion". Gate 1 already has the means — don't grant the glob. The CV exposes a granularity problem (subjectivity://** is too coarse, and a flat namespace offers nothing finer to grant), not a missing gate.
The design: a note-level owner tier (REJECTED — kept for the record)
Frontmatter visibility: owner on a note; sync carries it into the DB; the server excludes such notes from every visitor session's reachable set, regardless of role globs:
readable(note) = MatchesAnyCorpusGlob(role_globs, uri) <- existing gate 1 (role, frozen)
∧ NOT note.owner_only <- new AND term (note, live, owner-unilateral)
Why it's wrong, in the order the objections landed:
visibilityhas two levels and they are a different kind of thing.visibilityispublic | privateon writings, meaning how the front end renders (private → render thelocked_bodyteaser). It was never access control —visibility.gosays so outright. Addingownermakes one key carry a rendering mode and an admission rule:F-L-8recurring — one flag, two orthogonal concepts. D.1 flagged this exact collision and still leaned "reuse".- "owner" has no referent. It marks a note visible-to-owner — but the whole corpus is visible to the owner by definition. It annotates a constant. The only thing that varies is visitor reachability, so the frame is wrong, not merely the word: an honest name would have
visitoras its subject. - Therefore it is redundant. Once the axis is "visitor reachability", that is the axis the role glob already governs. A second knob on one axis is not a feature.
- The CV doesn't want hiding. It wants directed opening: not public by default, then granted on the roles/codes that should see it — a recruiter loop sees it, a passer-by doesn't. That is what the ordinary ACL is for.
What to do instead
The CV is an ordinary ACL problem: not public, opened per role/code.
- Today: a role that should see it grants a glob covering it; a role that shouldn't, doesn't. But subjectivity's flat namespace makes the real choice
subjectivity://**(all) or enumerate-every-note (the "Discipline-only" failure rejected below). The honest state today is all-or-nothing for subjectivity. - The question this note should have asked: granting some subjectivity needs something to grant — a subpath, a naming convention, or per-note enumeration. That collides with the flat-subjectivity vault rule. Rejected-alternative #2 refuses to "let the filesystem dictate semantics", then invents a mechanism to route around the constraint. If the ACL's unit is the path glob, a flat namespace is a decision to have no granularity — so either the flat rule gives, or subjectivity stays all-or-nothing by choice.
- Per-code targeting is the other half — shipped 2026-07-16, exactly as the reopening below argued.
code_corpus_denials(6395374b0;e2e/test/code-corpus-narrowing.spec.ts) is a pure-subtraction layer:readable = role glob hits AND no code deny hits, set intersection, unordered, so A.2's order-sensitivity objection never arises. The unit is a glob, the same language as the role's positive list:subjectivity://cvsubtracts one note from one invitation,subjectivity://**takes the genre back from that code (schema.sql:1034-1042). What remains all-or-nothing is the role side of a flat namespace.
Rejected alternatives (original; #2's reasoning is now the interesting one)
- Deny rows in the glob list (first-match-wins) — precisely the order-sensitive narrowing deferred by A.2; changes gate-1 algebra and role-editor UX; and the unit being protected is "this note is a record", not a path shape.
- A subpath or separate genre for records — subjectivity stays flat by vault rule (groups live in the index only); reorganizing content to satisfy an ACL lets the filesystem dictate semantics. (← reopen this: the ACL's unit IS the path.)
- Discipline-only (no wildcards, enumerate allows) — depends on the owner never being lazy; a new record note leaks by default under any wildcard. Acceptable as habit, failing as mechanism.
Open decision points (void — kept to show where it went wrong)
- D.1 field name: reuse
visibility:vs a freshowner_only: true.Leaning reuse, genre disambiguates.Both wrong — the mechanism shouldn't exist. Standing lesson: "Leaning X" in astatus: seednote is not a decision. It was read as one and shipped. - D.2 scope / D.3 Meili / D.4 default direction: void.
What survives the rejection
- The gap is real and half open.
subjectivity://**+ a record note = that role can read the record; a code can now subtract it (code_corpus_denials), but on the role side today's safety is still a hand-narrow grant, nothing structural. - A stale-index hazard, worth remembering for any future note-level ACL fact. The Meili leg of corpus search trusted the indexed copy of the flag. The index is written best-effort and asynchronously, so its copy can be stale, missing, or orphaned — and each reads as permissive. Any per-note ACL fact must be re-checked against the row (the DB is the authority; the index is only a candidate source) or it fails open. Found live:
corpus_readcorrectly withheld the note whilecorpus_searchreturned it whole — path, title, employer in the summary. - The closing claim of the original note was already false when written:
"no role glob currently admits— the audit'ssubjectivity/cv.md"subj-verifyrole grantssubjectivity://**, which admits it exactly. Nothing leaked only becausecv.mdpostdates the vault copy that was synced. The safety came from the CV not being synced yet, not from the globs — this note's own "a new record note leaks by default under any wildcard", live.