Skip to content

Commit 631ba44

Browse files
committed
docs(changeset): add the changeset for the #4586 actor-attribution seam
The Check Changeset gate was red: a user-visible behaviour change (identity-table writes now carry the true actor; `granted_by` / `reason` stop being null) with no `.changeset/*.md`. Minor across the five published packages whose surface moves — `spec` gains an authorable `ExecutionContext.attributedUserId` and the `HookContext.provenance` envelope, so this is additive API, not a pure fix. The body states the hard constraint the reviewer must be able to find later: the threaded actor is attribution ONLY and never the authorization subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
1 parent 37e02ea commit 631ba44

1 file changed

Lines changed: 60 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/plugin-auth": minor
5+
"@objectstack/plugin-audit": minor
6+
"@objectstack/plugin-security": minor
7+
---
8+
9+
feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586)
10+
11+
better-auth owns every write to the identity tables (`sys_member`, `sys_user`,
12+
`sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true` **on
13+
purpose** — the route already authorized the action under better-auth's own ACL,
14+
and ADR-0092 D2 refuses user-context writes to those tables outright. The
15+
consequence was that the human who clicked *make admin* was known exactly once,
16+
in the hook layer where the session exists, and then discarded: every
17+
`trackHistory` transition on `sys_member` recorded `user_id: null` / "system",
18+
and `sys_user_permission_set.granted_by` was written null by the auto-grant.
19+
"Who made this person an org admin?" had no answer in the platform's own audit
20+
log.
21+
22+
**What changed**
23+
24+
A request-scoped attribution seam, general rather than a `sys_member` special
25+
case:
26+
27+
| Layer | Before | After |
28+
|:--|:--|:--|
29+
| `ExecutionContext` | `userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED |
30+
| `HookContext` | `session`, `user` | new `provenance.attributedUserId`, split off the context beside `session` |
31+
| better-auth ObjectQL adapter | `{ isSystem: true }` | `{ isSystem: true, attributedUserId }` when a request scope is open |
32+
| audit writer | `user_id = session.userId ?? null` | falls back to `provenance.attributedUserId` when the session names nobody |
33+
| `auto-org-admin-grant` | `granted_by: null`, no `reason` | the attributed human in `granted_by`, plus a machine-provenance `reason` naming the writer and the triggering `sys_member` row |
34+
35+
Outside a request scope nothing changes: writes stay bare `{ isSystem: true }`
36+
and audit rows keep recording `null`. Absence is still never upgraded into a
37+
caller, and never written as a sentinel string (ADR-0118 D1/D2).
38+
39+
**Hard constraint — attribution is not authority**
40+
41+
`attributedUserId` is read by exactly one consumer, the audit writer, and by no
42+
security middleware. It never becomes `ExecutionContext.userId`, so it is never
43+
the subject the engine authorizes as: not RLS `current_user`, not the ownership
44+
stamp, not permission resolution. A context carrying only `attributedUserId`
45+
authorizes exactly like an empty context (ANONYMOUS), and a context carrying it
46+
beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing
47+
identity writes as the human would re-adjudicate a decision better-auth already
48+
made — the second adjudication track ADR-0095 D3 closed. The constraint is
49+
pinned by tests at three layers: the engine seam
50+
(`packages/objectql/src/engine.test.ts`), the better-auth adapter
51+
(`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the
52+
live HTTP route (a plain member still cannot promote themselves).
53+
54+
**For authors and plugin developers**
55+
56+
`attributedUserId` is authorable on `ExecutionContext` and readable as
57+
`ctx.provenance?.attributedUserId` in hooks. Use it to answer *who is
58+
responsible*; keep using `ctx.session` / `ctx.user` to decide *what is
59+
permitted*. The two are separate fields precisely so the distinction cannot be
60+
blurred by accident.

0 commit comments

Comments
 (0)