Skip to content

docs(skills): data-hooks.md speaks ADR-0090 D3 vocabulary - #15186

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-15177-data-hooks-role-vocabulary
Sep 4, 2026
Merged

docs(skills): data-hooks.md speaks ADR-0090 D3 vocabulary#15186
os-zhuang merged 1 commit into
mainfrom
claude/issue-15177-data-hooks-role-vocabulary

Conversation

@os-steve

@os-steve os-steve commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15177

What this is

skills/objectstack-data/references/data-hooks.md ships verbatim into customer
projects via npx skills add. ADR-0090 D3 makes "role" a reserved-forbidden word —
capability is permission_set, distribution is position, hierarchy is
business_unit, collaboration is team — with one documented carve-out, the
better-auth sys_member.role field. This page used the reserved word twelve times:
five of them quoting the session.roles retirement, which it stated three separate
times; six using the word as live vocabulary; one as an example query.

Maintainer ruling of 2026-09-04 on #15075 (comment 5535828546, corrected by
5535830096) chose option B: rewrite the page first, and admit only the residue to
the shrink-only baseline. That baseline entry is PR #15073's to make, not this PR's.

Occurrence list, before and after

Re-measured on the merge base efb35131 with grep -nowiE 'roles?' — the same
whole-word set the gate's own pattern (\brole(?:s)?\b, case-insensitive) matches,
so this list and the gate's count are the same measurement.

Before — 12 occurrences across 8 lines:

line occurrences text disposition
350 2 **No role list**session.roles was retired in 17.0.0 kept — the one retirement note
574 1 No `roles` here (retired in 17.0.0) folded away
575 1 never by a role name in a hook folded away
733 1 where: { role: 'admin' } rewritten
822 1 always hidden/masked for a role D3 vocabulary
837 2 Do NOT gate this on a role name … carries no role list folded away
838 1 `session.roles` was declared for years folded away
839 1 ctx.session?.roles?.includes(…) kept — the one warning
840 1 looked role-aware and was not D3 vocabulary
841 1 A per-role exemption D3 vocabulary

After — 3 occurrences across 2 lines:

line occurrences text
350 2 **No role list**session.roles was retired in 17.0.0 — the retirement note, quoted history
837 1 ctx.session?.roles?.includes(…) — the warning, quoting the API that never existed

The page now states the retirement once and warns once, which is the target the card
set. Both survivors are quoted history: they name a thing that was declared and
retired, which is the only way to warn against it.

Each rewrite

1. Line 350 — the ctx.session table row: unchanged

Kept verbatim. This is the one legitimate history statement, and the two rewrites
below both point back to it rather than restating it.

2. Lines 574-575 — the HookContext interface comment

The retirement note's second copy. Folded into one sentence, no reserved word;
the fact itself stays on the table row above.

-                             // No `roles` here (retired in 17.0.0) — privilege is judged
-                             // by the security service, never by a role name in a hook.
+                             // Privilege is judged by the security service,
+                             // never by a session claim.

3. Line 733 — the cross-object example query

-  const admin = await users.findOne({
-    where: { role: 'admin' }
+  const owner = await users.findOne({
+    where: { id: ctx.input.owner_id }
   });

A correction to the card's stated reason. The card says "no sys_user field is
named role". That is not so: packages/platform-objects/src/identity/sys-user.object.ts
does declare one (role: Field.text({ label: 'Platform Role', readonly: true, … }),
the better-auth admin plugin scalar). The disposition is unchanged, and the real
fact argues for it harder than the stated one did: ADR-0068 D2 stopped synthesizing
that scalar, and its only writer — the set_user_role action — was retired
(the removal note sits at sys-user.object.ts:282, and points at
sys_user_permission_set / admin_full_access as where platform-admin membership
actually lives). So the field exists but is a dead legacy column, and an example
that looks up "the admin" by it teaches authorization off a field the platform no
longer maintains. It is also not the D3 carve-out: the carve-out is
sys_member.role, and this query is not that field.

Choice of replacement, on the four axes:

  • Real business need. Nothing on the page consumes the result — the query exists
    to demonstrate that where is the canonical key. What a hook actually does with
    ctx.api.object(...).findOne is resolve a related record (an owner, a manager)
    to read its fields. Looking up "the admin" is not a measured usage; resolving a
    related user by id is.
  • Long-term soundness. An id lookup is contract-stable. A lookup by a vendor
    scalar the platform stopped synthesizing is a workaround fossilized into docs.
  • Making it structurally harder for an AI to get wrong. This is the deciding
    axis. where: { role: 'admin' } in a hooks reference is the seed of
    if (admin) { ... } authorization code in a customer project. An id lookup
    cannot be misread as an authorization decision.
  • No scope diffusion at startup stage. A permission-set check was the card's
    other offered option, and it is rejected on this axis: the page documents no
    permission-set API on ctx, so writing one into an example would advertise a
    capability this page cannot back — declared but not delivered. The minimal real
    field is the smaller, honest change.

The object name user is left as it is: this page uses tutorial-generic object
names throughout (account, contact, task, audit_log), and changing it is
outside this card's file-and-line surface.

4. Line 822 — the static-masking callout

D3 vocabulary. The paragraph re-wraps because the replacement phrase is longer than
what it replaces; the line count is unchanged at 5 and no content is bought by the
re-wrap.

-> For **static** field masking (a field is always hidden/masked for a role),
-> prefer declarative **field-level metadata** (secret/masked fields) — it applies
-> on every read path automatically. Use an `afterFind` hook only for masking that
-> depends on runtime logic the field metadata can't express. A single `afterFind`
-> subscription covers both `find` and `findOne`.
+> For **static** field masking (a field is always hidden/masked for a permission
+> set or position), prefer declarative **field-level metadata** (secret/masked
+> fields) — it applies on every read path automatically. Use an `afterFind` hook
+> only for masking that depends on runtime logic the field metadata can't
+> express. A single `afterFind` subscription covers both `find` and `findOne`.

5. Lines 837-842 — the masking-handler comment

The retirement note's third copy plus four live uses. The one useful warning is
kept — do not write ctx.session?.roles?.includes — and the rest is spelled in D3
vocabulary. The retirement fact is not restated; the comment points at the table row
instead. Six lines become five.

-    // ⚠️ Do NOT gate this on a role name. `ctx.session` carries no role list:
-    // `session.roles` was declared for years, never produced by any engine
-    // path, and retired in 17.0.0 — `ctx.session?.roles?.includes(…)`
-    // was always `undefined`, so a mask written that way looked role-aware and
-    // was not. A per-role exemption belongs in field-level permissions (the
-    // callout above), which the read path applies for you.
+    // ⚠️ Never gate this on a session claim: `ctx.session?.roles?.includes(…)`
+    // is always `undefined` (see the ctx table above), so a mask written that
+    // way never exempts anyone. A per-permission-set or per-position exemption
+    // belongs in field-level permissions (the callout above), which the read
+    // path applies for you.

"never exempts anyone" replaces "looked role-aware and was not" because it states
the actual runtime consequence: the optional chain yields undefined, which is
falsy, so the exemption never fires and everyone is masked.

Residue, measured with the widened walker

PR #15073 widens this gate to walk the published catalog's references/ pages —
references leaves SKIP_DIRS, and content/docs/references is excluded by path
instead. That change is not in this PR. It was applied in a throwaway detached
worktree at this branch's head, measured, and the worktree destroyed; the mutation
was confirmed on disk before each run (the removed literal at 0 hits, both injected
fragments at 1 hit each, git diff --stat showing the file).

tree measured, widened walker data-hooks.md count
merge base efb35131 (page before this PR) 12
this branch, commit 7add396f 3

Both runs exit 1, which is correct and expected: with the walker widened the page is
in the walk for the first time and is not yet in scripts/role-word-baseline.json.
Adding that entry is PR #15073's authorized act, for the residue only, and
scripts/role-word-baseline.json is untouched here.

The residual three, quoted from the branch:

350:  | `ctx.session` | … **No role list** — `session.roles` was retired in 17.0.0: it was
      declared but never produced, so every read was `undefined`. |
837:  // ⚠️ Never gate this on a session claim: `ctx.session?.roles?.includes(…)`

Without the walker change, pnpm check:role-word at this head is green and does not
see the page at all — Scanned: 224 .md/.mdx file(s) read across 2 root(s) — content/docs 190, skills 34, with the references/ trees skipped by name.

Ratchet arithmetic

scripts/check-skills-token-ratchet.mjs is the per-file budget for the published
catalog. There is no per-file line ceiling over skills/**:
scripts/pm/check-skill-line-ratchet.mjs prices .claude/skills/** only. It was run
anyway and is green.

measure before after delta
data-hooks.md lines 980 979 −1
data-hooks.md tokens 9703 9674 −29
data-hooks.md ceiling 12611 12611 0 (untouched)
data-hooks.md headroom 2908 2937 +29
objectstack-data package lines 3737 3736 −1
objectstack-data package tokens 38443 38414 −29
ratcheted authored total 139986 139957 −29
whole shipped bundle 156816 156787 −29

Net shrink in both currencies, so nothing is bought and no ceiling moves.

Gate verdict after: ✓ check-skills-token-ratchet: skills/objectstack-data/references/data-hooks.md is 9674 tokens (ceiling 12611; headroom 2937).

One deferral, flagged rather than taken. The ratchet's shrink-only discipline
says a ceiling "may be LOWERED by any PR that shrinks its file" and calls lowering
"always legitimate and encouraged". Banking this shrink would mean lowering the row
to 9674 — but that row lives in scripts/check-skills-token-ratchet.mjs, and this
card's file surface is the one page, no script. Left for whoever owns that call; the
headroom is printed on every gate run, so it is not hidden.

Gates

Derived after the commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths — the tool reads its own change set
from the merge base), which returned 20 commands, plus the three the dispatch named
by hand. Every exit code was captured by redirecting to a file first and reading
$? before any pipe. All run at head 7add396f, the commit this PR pushes.

gate exit verdict
check-ci-filter-parity 0 all 143 declared cross-package globs covered
check-closing-keyword-parity 0 3 parsers agree on all 9 keywords; 5 files carrying the grammar, all registered
check-closing-keyword-parity --self-test 0 24 assertions, 5 mutations each driven to red
check-comment-mask-corpus 0 5864 files, 0 disagree, 0 unparseable
check-skills-token-ratchet 0 36 authored files within ceilings; 11 generator-owned measured
check-skills-token-ratchet --self-test 0 64 cases pass
check:doc-formula-expressions 0 22 record-scoped formula examples across 426 files judged clean
check:skill-refs 0 9 generated files in sync with packages/spec
check:agent-test-spelling 0 0 violations, 439 files swept
check:corpus-claim-drift 0 self-test green; ledger holds
check:cross-package-test-inputs 0 26 packages read outside themselves, all declared
check:doc-authoring 0 831 pinned sites across 231 files, no growth
check:nul-bytes 0 75 assertions over a temp git repo
check:pm-governed-merges 0 245 assertions
check:refd-timer-probe 0 5859 source files swept
check:role-word 0 224 files read across 2 roots; ledger unchanged at 43 files / 120 occurrences
check:skill-compatibility 0 11 SKILL.md reconciled against 79 workspace packages
check:skill-frame-sync 0 2 copies of the decision frame structurally isomorphic
check:skill-identifier-liveness 0 465 citations over 46 published files; 8 exhaustive sections, 0 gaps
check:watch-hint-literal 0 48 declarations across 4 rostered names
check:pm-skill-id-lint 0 23 files clean — no issue numbers in published skill text
check:pm-skill-ratchet 0 line ratchet green (prices .claude/skills/**, not this page)
pnpm lint 0 whole-repo eslint . --no-inline-config, no narrowing, clean

check:doc-formula-expressions first returned exit 3, which is that gate's own
PREREQUISITE NOT MET code and explicitly not a finding — @objectstack/formula
and @objectstack/lint were unbuilt in a fresh worktree. Both were built and the
gate re-run to the real verdict above.

Also carried out: grep -naP control-character sweep over the edited file, clean.

Scope

One file, one commit. scripts/role-word-baseline.json untouched; no script
touched; no other page touched. Governed surface, so this stays a draft for the
dispatching seat to review at the contract tier and request the approvers.
skip-changeset applies — nothing under skills/** is published by a changeset.

Related cards, none of them acted on here: PR #15073 lands after this page and
remains open until then; #15061 remains open behind it; the ruling on #15075 is
already recorded there.


Generated by Claude Code

The published catalog page stated the `session.roles` retirement three times
and used the reserved word "role" as live vocabulary six more times. D3 makes
"role" reserved-forbidden (capability = permission_set, distribution =
position, hierarchy = business_unit); this page ships verbatim to third-party
projects via `npx skills add`, so every live use teaches an AI reader the
reserved word.

Kept: the one retirement note (the `ctx.session` table row) and the one useful
warning (never write `ctx.session?.roles?.includes`). Rewritten: the duplicate
retirement note in the `HookContext` comment folds into "privilege is judged by
the security service, never by a session claim"; the masking callout says
"for a permission set or position"; the masking comment spells the rest in D3
vocabulary. The cross-object example no longer queries a field named `role` --
`sys_user.role` does exist as a legacy better-auth admin scalar, but ADR-0068
D2 stopped synthesizing it and its only writer was retired, so an example
looking up "the admin" by it teaches authorization off a dead field; it now
resolves a related user by id, which is what a hook actually does.

Residue: 3 occurrences, all quoted history, which is what the follow-up
baseline entry covers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@os-zhuang
os-zhuang marked this pull request as ready for review September 4, 2026 05:33
@os-zhuang
os-zhuang enabled auto-merge September 4, 2026 05:33
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 5f5511f Sep 4, 2026
36 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15177-data-hooks-role-vocabulary branch September 4, 2026 06:22
os-steve added a commit that referenced this pull request Sep 4, 2026
Brings in PR #15186 (#15177), which rewrote
skills/objectstack-data/references/data-hooks.md into ADR-0090 D3
vocabulary, leaving 3 quoted-history occurrences of the reserved word
where this branch's widened scan first met 12.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
os-steve added a commit that referenced this pull request Sep 4, 2026
The maintainer authorised the MAINTAINER-ONLY `--update` for this page's
residue and for this PR only, after PR #15186 rewrote the page into
ADR-0090 D3 vocabulary and left 3 occurrences that cannot be reworded
without falsifying what they document.

Both are quoted pre-rename history — D3's own named legitimate KIND:

  line 350  **No role list** - `session.roles` was retired in 17.0.0: it
            was declared but never produced, so every read was undefined
  line 837  Never gate this on a session claim: ctx.session?.roles?.…

Naming the retired identifier is the sentence's entire content; renaming
it makes the sentence false.

`--update` rewrites the whole baseline from the current tree, so the
result was inspected key by key rather than skimmed. Exactly one entry
was added and nothing else moved:

  entries 43 -> 44, occurrences 120 -> 123 (delta 3)
  ADDED   skills/objectstack-data/references/data-hooks.md: 3
  REMOVED (none)
  CHANGED (none)

check-role-word: green, 236 files across 2 roots, ledger 44 file(s) /
123 occurrence(s). check-corpus-claim-drift: green, ledger unchanged at
2 file(s). Both population pins hold: 236 walked, 12 published reference
pages, 0 under the generated content/docs/references/ tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants