2026-09-23·by Sijie Wang#software#project#standmeet

subjectivity-record-notes-acl

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:

knoblives ongoverns
role globrolecan the agent read it
show_as_sourcenotehaving 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.
about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →