Subjectivity record notes: an ACL granularity problem

Updated · View the entry on sijie.xyz ↗

(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_source note having read it, may it cite it — defaults to false for subjectivity since 017c37d23 (2026-09-05): corpus.create / corpus.update used 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, MatchesAnyCorpusGlob in path_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 as code_corpus_denials (6395374b0 backend, 9a08ab6a2 GUI 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:

  1. visibility has two levels and they are a different kind of thing. visibility is public | private on writings, meaning how the front end renders (private → render the locked_body teaser). It was never access control — visibility.go says so outright. Adding owner makes one key carry a rendering mode and an admission rule: F-L-8 recurring — one flag, two orthogonal concepts. D.1 flagged this exact collision and still leaned "reuse".
  2. "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 visitor as its subject.
  3. 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.
  4. 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://cv subtracts 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 fresh owner_only: true. Leaning reuse, genre disambiguates. Both wrong — the mechanism shouldn't exist. Standing lesson: "Leaning X" in a status: seed note 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_read correctly withheld the note while corpus_search returned 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 subjectivity/cv.md" — the audit's subj-verify role grants subjectivity://**, which admits it exactly. Nothing leaked only because cv.md postdates 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.