Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b0375211 | ||
|
|
f07b4c73b1 | ||
|
|
bf3bd6c477 | ||
|
|
5c1e8d290e | ||
|
|
e32d77e5a1 | ||
|
|
dcf4220823 | ||
|
|
f5e3165ea5 | ||
|
|
ee8c593858 | ||
|
|
7ef620cda7 |
@@ -0,0 +1,380 @@
|
|||||||
|
# Hierarchy Schema Contract (D2)
|
||||||
|
|
||||||
|
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
|
||||||
|
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
|
||||||
|
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
|
||||||
|
document turns the ratified hierarchy into a concrete schema contract:
|
||||||
|
tables, cardinalities, constraints, and ownership/transfer semantics. It is
|
||||||
|
the prerequisite for the hierarchy command family and for the RBAC grant
|
||||||
|
model (contract 2, `docs/requirements/rbac-grant-model.md`).
|
||||||
|
|
||||||
|
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
|
||||||
|
explicit (§1.1); record class extended to include `hierarchy_grants`
|
||||||
|
(§1.1); provenance corrections on legacy tables and the planning `projects`
|
||||||
|
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
|
||||||
|
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
|
||||||
|
(§3.3); transfer transaction includes its audit write (§4.3); ownership
|
||||||
|
invariant completed via contract 2 with the both-sides rule marked as new
|
||||||
|
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
|
||||||
|
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
|
||||||
|
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
|
||||||
|
MUST (allowlist, command surface, audit, corrected cardinality witness).
|
||||||
|
|
||||||
|
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
|
||||||
|
reconciled with §5.2 — the transaction's writes are the single class-row
|
||||||
|
mutation plus that mutation's §5.2 audit writes (event + outbox record),
|
||||||
|
not "exactly two writes"; §6.3 extended with a closed writer-coverage
|
||||||
|
witness so an unregistered internal writer cannot pass a registered-route
|
||||||
|
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
|
||||||
|
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
|
||||||
|
cites §6.5; measured against `origin/contract/rbac-grants` head
|
||||||
|
`501112d2`.)
|
||||||
|
|
||||||
|
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
|
||||||
|
extended to raw SQL — it now also fails on class-table name literals
|
||||||
|
inside SQL strings or tagged SQL templates outside the allowlist, so a
|
||||||
|
raw-SQL writer that touches no schema symbol is still caught.
|
||||||
|
|
||||||
|
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
|
||||||
|
raw-SQL execution primitive outside the allowlist fails the assertion
|
||||||
|
regardless of its SQL content, closing the evasion where a
|
||||||
|
dynamically constructed table name carries neither a schema symbol nor
|
||||||
|
a class-table literal. The detection claim is now coextensive with
|
||||||
|
what the three prongs statically see.
|
||||||
|
|
||||||
|
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
|
||||||
|
corrected to three (F8); §6.3(b) gains the allowlist composition rules
|
||||||
|
(no generic raw-SQL helper is allowlisted; an allowlisted module may
|
||||||
|
not export caller-supplied-SQL execution) and fails outright on
|
||||||
|
runtime code-construction primitives; the detection claim is scoped
|
||||||
|
honestly to the stated syntactic forms, with evasions beyond static
|
||||||
|
reach assigned to §5.1 review/audit rather than claimed for CI.
|
||||||
|
|
||||||
|
Revision 7 (terra r6 new F9): the false-positive remedy no longer
|
||||||
|
contradicts the composition rules — legitimate non-hierarchy raw
|
||||||
|
execution (e.g. the db package's migration runner) is dispositioned
|
||||||
|
onto a second closed enumerated list, the infrastructure register,
|
||||||
|
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
|
||||||
|
from the writer allowlist, and importable only by registered modules
|
||||||
|
or the operational entry points.
|
||||||
|
|
||||||
|
Revision 8 (terra r7 residual F9): the register's import rule made
|
||||||
|
satisfiable by the live tree — imports are checked re-export-aware
|
||||||
|
(package barrels followed), and each registered module carries its own
|
||||||
|
closed importer enumeration, which may name operational entry points
|
||||||
|
such as the Gateway's startup migration hook; named importers stay
|
||||||
|
subject to prongs (i)/(ii) and gain no writer standing.
|
||||||
|
|
||||||
|
Revision 9 (terra r8 F10): revision 8 called the Gateway database
|
||||||
|
module the runner's "one live importer today". That was false — the
|
||||||
|
measured production importer set has four members. The enumeration
|
||||||
|
example now lists the complete measured set, and the import analysis
|
||||||
|
is extended to resolve literal dynamic `import()` routes, which two of
|
||||||
|
the four members use.
|
||||||
|
|
||||||
|
Scope: the tenancy/authorization structure record class — companies,
|
||||||
|
estates, platform-projects, workspaces, hierarchy grants, their parentage,
|
||||||
|
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
|
||||||
|
semantics (contract 2), roll-up projection semantics (contract 8), kanban
|
||||||
|
planning entities inside workspaces (SOT §5), migration or retirement of
|
||||||
|
legacy flat data (future work; see §1.3).
|
||||||
|
|
||||||
|
## 1. Record class and placement
|
||||||
|
|
||||||
|
1. The **tenancy/authorization structure record class** defined by
|
||||||
|
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
|
||||||
|
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
|
||||||
|
in the class. Every rule addressed to "the class" in this contract
|
||||||
|
(payload prohibition, mutation path, audit) binds all five tables. Class
|
||||||
|
rows carry parentage, naming, grant, and audit-linkage data only — never
|
||||||
|
task, plan, or any business/orchestration payload.
|
||||||
|
References from business/orchestration rows into the class are limited
|
||||||
|
to exactly one form: the canonical `workspace_id` tenancy column that
|
||||||
|
REQ-TEN-001 requires on every canonical row, referencing
|
||||||
|
`workspaces.id`. No business/orchestration row may reference a company,
|
||||||
|
estate, platform-project, or grant id in any position, and no
|
||||||
|
business/orchestration row may reference a workspace id in any
|
||||||
|
non-tenancy position (dependency, claim target, work subject).
|
||||||
|
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
|
||||||
|
`workspace_id` obligation binds business/orchestration rows and does not
|
||||||
|
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
|
||||||
|
anchor the obligation points at.
|
||||||
|
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
|
||||||
|
in `packages/db/src/schema.ts`) are not part of this class. What A1
|
||||||
|
§8.1.4 pins is narrower: the planning `projects` table and
|
||||||
|
`platform_projects` stay distinct tables. This contract adds, as new
|
||||||
|
policy ratified here: neither `teams` nor `projects` is repurposed as a
|
||||||
|
hierarchy table. Their eventual migration or retirement is future work
|
||||||
|
that no existing REQ assigns; it is out of scope here.
|
||||||
|
|
||||||
|
## 2. Tables and cardinalities
|
||||||
|
|
||||||
|
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
|
||||||
|
The existing `projects` table is Brain planning data (so labeled in
|
||||||
|
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
|
||||||
|
MUST NOT merge the two. (A rename of either remains an implementation-PR
|
||||||
|
decision under A1; this contract pins only that they stay distinct tables.)
|
||||||
|
|
||||||
|
1. `companies` — id (uuid pk), name, slug (unique per deployment),
|
||||||
|
created_at, updated_at. N per deployment (D2).
|
||||||
|
2. `estates` — id, name, slug, `company_id` NOT NULL →
|
||||||
|
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
|
||||||
|
company holds any number of estates.
|
||||||
|
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
|
||||||
|
`estates.id` ON DELETE RESTRICT. Exactly one estate per
|
||||||
|
platform-project; an estate holds any number of platform-projects.
|
||||||
|
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
|
||||||
|
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
|
||||||
|
per workspace. This table is the referent of every `workspace_id` column
|
||||||
|
the SOT requires on canonical rows.
|
||||||
|
5. **Chain resolution is by construction.** Because every parent FK is NOT
|
||||||
|
NULL and single-valued (one FK column, no parentage edge tables, no
|
||||||
|
multi-parent forms, no nullable "detached" states), each workspace
|
||||||
|
resolves to exactly one platform-project → estate → company chain (A1
|
||||||
|
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
|
||||||
|
many children per parent is valid data.
|
||||||
|
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
|
||||||
|
`estates.slug` is unique within its company, `platform_projects.slug`
|
||||||
|
within its estate, `workspaces.slug` within its platform-project
|
||||||
|
(composite unique constraints). Display names are unconstrained beyond
|
||||||
|
NOT NULL.
|
||||||
|
7. No hierarchy table carries a `metadata` jsonb column or any
|
||||||
|
free-form payload field. The columns declared in this section and §3
|
||||||
|
are exhaustive: a class table's column set is exactly its declared set
|
||||||
|
(verified per §6.2) — nothing else (A1 §8.1.2).
|
||||||
|
|
||||||
|
## 3. Grant attachment points
|
||||||
|
|
||||||
|
The grant vocabulary (which roles exist, what each permits, how evaluation
|
||||||
|
and revocation work) is contract 2. This contract pins only the schema
|
||||||
|
shape contract 2 attaches to:
|
||||||
|
|
||||||
|
1. `hierarchy_grants` — id, subject (exactly one of `user_id` → `users.id`,
|
||||||
|
`team_id` → `teams.id`; CHECK-enforced exactly-one-of), target (exactly
|
||||||
|
one of `company_id`, `estate_id`, `platform_project_id`;
|
||||||
|
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
|
||||||
|
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
|
||||||
|
`users.id`, created_at.
|
||||||
|
2. Uniqueness: at most one grant row per (subject, target, role). Because
|
||||||
|
the subject and target columns are nullable by design, ordinary
|
||||||
|
PostgreSQL composite uniqueness treats NULLs as distinct and would not
|
||||||
|
enforce this. The implementation MUST use a single
|
||||||
|
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
|
||||||
|
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
|
||||||
|
equivalent partial unique indexes (one per subject×target form). The
|
||||||
|
pinned Drizzle ORM supports `nullsNotDistinct()`.
|
||||||
|
3. Delete actions are split by column class:
|
||||||
|
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
|
||||||
|
ON DELETE CASCADE — the one permitted cascade in this class. A grant
|
||||||
|
on a deleted node is meaningless and fail-open if retained. Cascaded
|
||||||
|
grant deletions are audited per §5.2.
|
||||||
|
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
|
||||||
|
RESTRICT. The identity contract (§7.3) gates user deletion today and
|
||||||
|
defines no team-deletion rule; this contract does not invent one.
|
||||||
|
These FKs stay RESTRICT until an explicit deletion-and-retention
|
||||||
|
contract ratifies otherwise.
|
||||||
|
4. Workspace-level access is evaluated, not stored here: a grant at any of
|
||||||
|
the three levels evaluates down the chain to workspace-scoped
|
||||||
|
authorization (A1 §8.1.3). No `workspace_id` column exists on
|
||||||
|
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
|
||||||
|
mechanism inside the SOT schema, and the chain adds where grants can be
|
||||||
|
declared, never a bypass.
|
||||||
|
|
||||||
|
## 4. Ownership and transfer
|
||||||
|
|
||||||
|
"Assets are transferable subject to the structure" (PRD Part I §4):
|
||||||
|
|
||||||
|
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
|
||||||
|
workspace → new platform-project, platform-project → new estate, estate
|
||||||
|
→ new company. Nothing else in the class or the SOT changes: business
|
||||||
|
and orchestration rows inside affected workspaces are untouched, keep
|
||||||
|
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
|
||||||
|
"chain maintenance").
|
||||||
|
2. Transfer authorization requires authority over BOTH the source and the
|
||||||
|
destination parent. This both-sides predicate is **new policy
|
||||||
|
introduced by this contract pair** (D2/A1 do not state it); its
|
||||||
|
evaluation semantics are contract 2 §5. The structural half — that the
|
||||||
|
transfer command evaluates it before mutating — binds here.
|
||||||
|
3. A transfer transaction mutates exactly one class-table row — the
|
||||||
|
single-row parent-FK update — and contains, beyond that, only the
|
||||||
|
§5.2 audit writes for that mutation (the audit event and its
|
||||||
|
hierarchy-outbox record, committing in the same transaction). No other
|
||||||
|
class, business, or orchestration row changes. There are no multi-row
|
||||||
|
transfer batches at the schema level; bulk moves are N audited
|
||||||
|
transfers.
|
||||||
|
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
|
||||||
|
grant structure: a "company owner" is a subject with an `owner` grant
|
||||||
|
on that company or an ancestor (contract 2 §2), not a column. The
|
||||||
|
ownership invariant across the contract pair: a node may hold zero
|
||||||
|
direct owner grants (authority can derive from an ancestor grant); node
|
||||||
|
creation names the initial `owner` grant in the same audited operation
|
||||||
|
and the wizard seeds the first company's owner the same way (contract 2
|
||||||
|
§4.3); transfer and revocation semantics are contract 2 §§5–6. This
|
||||||
|
avoids column-encoded authority of the kind the legacy schema carries
|
||||||
|
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
|
||||||
|
`team_members.role` is a further authority field — none of them
|
||||||
|
evaluable under a grant model).
|
||||||
|
|
||||||
|
## 5. Mutation path, audit, and deletion
|
||||||
|
|
||||||
|
1. All hierarchy mutations flow through the same sole-writable-SOT,
|
||||||
|
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
|
||||||
|
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
|
||||||
|
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
|
||||||
|
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
|
||||||
|
substitute. Concretely:
|
||||||
|
- Every create, rename, transfer, grant create/change/revoke, and
|
||||||
|
delete — including every grant deletion cascaded by a node delete —
|
||||||
|
emits a semantic audit event carrying actor, verb, target, and (for
|
||||||
|
transfers) source and destination parents, with the correlation,
|
||||||
|
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
|
||||||
|
defines.
|
||||||
|
- The state change and its audit event(s) commit in the same
|
||||||
|
transaction, delivered through a transactional outbox. Hierarchy
|
||||||
|
events are not workspace-scoped rows and do not ride the workspace
|
||||||
|
outbox; they get an equivalent hierarchy outbox under the same
|
||||||
|
append-only, same-transaction rules.
|
||||||
|
- **Deletion-safe linkage:** audit events reference their target by an
|
||||||
|
immutable snapshot (id, slug, and parent chain at event time), never
|
||||||
|
by a foreign key into the class tables, so append-only events survive
|
||||||
|
the deletion of their target.
|
||||||
|
3. Deletion is fail-closed bottom-up: a hierarchy record with children
|
||||||
|
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
|
||||||
|
operation subject to the kanban SOT's own rules and is not granted any
|
||||||
|
new semantics by this contract.
|
||||||
|
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
|
||||||
|
roll-up read mutates nothing — not hierarchy state, and not business or
|
||||||
|
orchestration state: it must not mutate, claim, order, or gate
|
||||||
|
workspace work. Contract 8 owns projection details but cannot narrow
|
||||||
|
this rule. This contract additionally guarantees the chain roll-ups
|
||||||
|
aggregate over is unique and non-null (§2.5).
|
||||||
|
|
||||||
|
## 6. Verification requirements
|
||||||
|
|
||||||
|
Binding on the implementing PRs (extends A1 §8.3):
|
||||||
|
|
||||||
|
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
|
||||||
|
with a null parent FK refused; insert with one valid parent accepted;
|
||||||
|
two siblings under one parent accepted (the control proving the
|
||||||
|
constraint rejects only what §2.5 forbids); catalog assertion that each
|
||||||
|
child table has exactly one parent-FK column and no parentage edge
|
||||||
|
table exists. Composite slug uniqueness per parent (duplicate slug
|
||||||
|
under same parent refused; same slug under different parents accepted).
|
||||||
|
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
|
||||||
|
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
|
||||||
|
(subject, target, role) row refused for each of the six subject×target
|
||||||
|
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
|
||||||
|
`granted_by`, and all `name`/`slug` columns witnessed.
|
||||||
|
2. Column allowlist: an information_schema assertion that each class
|
||||||
|
table's column set is exactly the set declared in §2/§3 — the bounded
|
||||||
|
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
|
||||||
|
3. Command surface: two witnesses, both required (§5.1). (a) Route
|
||||||
|
inventory: an assertion over the Gateway's registered hierarchy
|
||||||
|
routes/commands proving the registered mutation surface is exactly the
|
||||||
|
declared hierarchy command family — no generic CRUD endpoint. (b)
|
||||||
|
Writer coverage — the closed allowlist a route inventory cannot
|
||||||
|
provide: a static CI assertion over the Gateway and package sources
|
||||||
|
with three prongs, each bound to one explicitly enumerated allowlist
|
||||||
|
of hierarchy command/repository modules. (i) Symbol prong: write
|
||||||
|
references to the class-table schema symbols (insert, update, delete)
|
||||||
|
occur only in allowlisted modules. (ii) Literal prong: a class-table
|
||||||
|
name appearing inside a SQL string or tagged SQL template outside the
|
||||||
|
allowlist fails the assertion — this is what catches a raw-SQL writer
|
||||||
|
that references no schema symbol. (iii) Raw-execution prong: any call
|
||||||
|
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
|
||||||
|
driver-level query/execute) outside the allowlist fails the
|
||||||
|
assertion, regardless of what the SQL string contains or how it is
|
||||||
|
constructed — the call site is statically detectable even when a
|
||||||
|
dynamically assembled table name is not, so a raw writer with a
|
||||||
|
runtime-built identifier is caught by its primitive, not its
|
||||||
|
payload. Two composition rules keep prong (iii) meaningful: the
|
||||||
|
allowlist names hierarchy command/repository modules only — a
|
||||||
|
generic raw-SQL helper or database-utility module is never
|
||||||
|
allowlisted; and an allowlisted module MUST NOT export a function
|
||||||
|
that executes caller-supplied SQL (such an export is itself a
|
||||||
|
raw-execution primitive, and the exporting module is treated as
|
||||||
|
unallowlisted for prong (iii) if it does). Legitimate raw execution
|
||||||
|
that is not a hierarchy writer — e.g. the migration runner in the
|
||||||
|
db package — lives on a second, separately enumerated
|
||||||
|
**infrastructure register**, distinct from the writer allowlist and
|
||||||
|
equally closed. A registered module is exempt from prong (iii) only:
|
||||||
|
prongs (i) and (ii) apply to it with no exemption, so it can hold no
|
||||||
|
class-table schema symbol or class-table SQL literal, and it can
|
||||||
|
never appear on the writer allowlist. To close the laundering path,
|
||||||
|
the same assertion checks imports, and the import analysis is
|
||||||
|
**re-export-aware**: it follows package barrels and re-exports, so a
|
||||||
|
route hidden behind an index module is still a route — and it
|
||||||
|
resolves literal dynamic imports the same way: an
|
||||||
|
`await import('<literal specifier>')` is an import edge like any
|
||||||
|
static import, not an evasion of the analysis (a dynamic import of
|
||||||
|
the db package whose specifier is not a literal fails the assertion
|
||||||
|
outright, because it makes the import graph unanalyzable). A
|
||||||
|
registered module may be imported only by other registered modules
|
||||||
|
or by importers named on that module's own closed importer
|
||||||
|
enumeration in the register — operational entry points such as the
|
||||||
|
migration/bootstrap CLI or the Gateway's startup migration hook.
|
||||||
|
The enumeration names the complete permitted production consumer
|
||||||
|
set, and completeness is measured, not asserted: the migration
|
||||||
|
runner's measured production importer set today has four members —
|
||||||
|
the Gateway database module (reached through the db package
|
||||||
|
barrel), the storage package's Postgres adapter, and two mosaic CLI
|
||||||
|
commands, the fleet-backlog command and the gateway verify command,
|
||||||
|
both routed through literal dynamic imports of the db package — so
|
||||||
|
its enumeration names those four. A module that only receives the
|
||||||
|
runner's functions by parameter injection (the gateway schema-check
|
||||||
|
module takes them as arguments from the verify command) has no
|
||||||
|
import edge of its own and is not enumerated. Any import route
|
||||||
|
outside the enumeration fails the assertion. Being a
|
||||||
|
named importer confers nothing else: the importer stays fully
|
||||||
|
subject to prongs (i) and (ii), gains no writer-allowlist standing,
|
||||||
|
and whether it uses the registered module beyond its operational
|
||||||
|
purpose is a §5.1 review question, not a static claim. Runtime code-construction
|
||||||
|
primitives (`eval`, `new Function`) anywhere in the scanned sources
|
||||||
|
fail the assertion outright, allowlist or not. Schema definitions
|
||||||
|
and generated migrations are excluded from the literal prong; a
|
||||||
|
false positive is resolved in the same PR by adding the module to
|
||||||
|
the one enumerated list its role permits — the writer allowlist for
|
||||||
|
a hierarchy command/repository module, the infrastructure register
|
||||||
|
for non-hierarchy raw execution — never by weakening the assertion,
|
||||||
|
and neither list may take a module the composition rules bar from
|
||||||
|
it. Both lists are closed, and the assertion's detection
|
||||||
|
claim is exactly its prongs: it statically surfaces every writer
|
||||||
|
expressed as a schema-symbol reference, a class-table SQL literal, a
|
||||||
|
raw-execution call site, or runtime code construction. An evasion
|
||||||
|
engineered outside those syntactic forms is a §5.1 violation that
|
||||||
|
review and audit own — the witness does not claim to catch what
|
||||||
|
static analysis cannot see, and any such evasion found later is
|
||||||
|
corrected as a conformance defect, not grandfathered.
|
||||||
|
4. Audit witnesses: for each mutation class (create, rename, transfer,
|
||||||
|
grant create/change/revoke, delete) — the event exists after commit
|
||||||
|
with actor/verb/target and same-transaction atomicity; a rolled-back
|
||||||
|
mutation leaves no event (rollback witness); a node delete's cascaded
|
||||||
|
grant deletions are each covered by events; events survive deletion of
|
||||||
|
their target (query the events of a deleted node).
|
||||||
|
5. Transfer tests: parent-FK update moves the subtree resolution and
|
||||||
|
modifies zero business/orchestration rows (row-count and content
|
||||||
|
assertions on workspace contents before/after); transfer without
|
||||||
|
authority on the source or on the destination side is refused (with
|
||||||
|
contract 2 §7.8).
|
||||||
|
6. Deletion tests: delete with children refused at the database level;
|
||||||
|
delete of a leaf cascades its grants and nothing else; deleting a user
|
||||||
|
or team that is a grant subject (or `granted_by` referent) is refused
|
||||||
|
(RESTRICT witnesses for §3.3).
|
||||||
|
7. Negative tests: no business/orchestration table accepts a company,
|
||||||
|
estate, platform-project, or grant id in any reference position, and
|
||||||
|
none accepts a workspace id in any non-tenancy position; the canonical
|
||||||
|
tenancy FK control — a business row inserted with a valid
|
||||||
|
`workspace_id` succeeds, with an invalid one is refused; roll-up
|
||||||
|
endpoints mutate no canonical state anywhere (assert zero writes across
|
||||||
|
hierarchy AND workspace tables, not hierarchy only); readers see
|
||||||
|
aggregates only over workspaces they are authorized on, with no
|
||||||
|
cross-tenant existence oracles (A1 §8.3 acceptance 3).
|
||||||
|
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
|
||||||
|
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
|
||||||
|
in the `test` CI step; mocked specs cannot witness database constraints.
|
||||||
|
|
||||||
|
## Ruling request
|
||||||
|
|
||||||
|
Ratify sections 1–6 as written, with one decision embedded: hierarchy
|
||||||
|
records carry no owner column — ownership is expressed solely through
|
||||||
|
grants (§4.4) — say "agreed" or name the ownership model you want.
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
# Identity Account-Lifecycle Contract
|
|
||||||
|
|
||||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
|
|
||||||
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
|
|
||||||
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
|
|
||||||
enforceable policy. It also carries the bootstrap/first-admin invariant from
|
|
||||||
issue #1430, folded in here after PR #1431's independent review showed the
|
|
||||||
quick-fix approach was insufficient.
|
|
||||||
|
|
||||||
Revision 2: addresses the 9 findings of the independent review
|
|
||||||
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
|
|
||||||
tightened (§3), canonical email split from provider claims (§5), external
|
|
||||||
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
|
|
||||||
JIT default precedence and first-admin SSO path defined (§2, §4),
|
|
||||||
deactivation made measurable (§7.1), deletion kept in scope and the existing
|
|
||||||
hard-delete endpoint required to fail closed (§7.3), workspace identity
|
|
||||||
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
|
|
||||||
(§8), factual labels corrected (§7.3, §8.1).
|
|
||||||
|
|
||||||
Revision 3: addresses the residuals and new findings of the revision-2
|
|
||||||
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
|
|
||||||
`users.emailVerified` added to the canonical set with a defined reset rule on
|
|
||||||
email change (§5.1–5.2), external-principal uniqueness moved to
|
|
||||||
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
|
|
||||||
delete affordances (web admin page, `mosaic auth users delete`) required to
|
|
||||||
be removed or disabled with a defined user-visible state (§7.3), the
|
|
||||||
admin-creation switch removed in favor of plain admin authorization (§2.3),
|
|
||||||
and §8 extended with observables for IdP removal, forward-auth non-use,
|
|
||||||
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
|
|
||||||
positive/expiry-bound step-up cases.
|
|
||||||
|
|
||||||
Scope: account creation, bootstrap, federated login, account linking, claim
|
|
||||||
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
|
|
||||||
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
|
|
||||||
(contract 1), sensitive-data custody (contract 7 / D14).
|
|
||||||
|
|
||||||
## 1. System of record
|
|
||||||
|
|
||||||
1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
|
|
||||||
the only account system of record. All foreign keys reference `users.id`.
|
|
||||||
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
|
|
||||||
through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
|
|
||||||
They never own accounts. Removing an IdP removes a login method, not users.
|
|
||||||
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
|
|
||||||
is configured for a deployment, the shim is demoted: it may stay as network
|
|
||||||
perimeter, but no application code may read identity from its headers.
|
|
||||||
4. **Account ≠ workspace membership.** Creating an account (by any path:
|
|
||||||
bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
|
|
||||||
hierarchy grant, and no workspace-scoped authority (native-kanban SOT
|
|
||||||
REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
|
|
||||||
role (`member` | `admin`), not workspace membership. Workspace grants are
|
|
||||||
defined by contract 2; until then a fresh account can authenticate and
|
|
||||||
holds no workspace authority.
|
|
||||||
|
|
||||||
## 2. Registration gating
|
|
||||||
|
|
||||||
Measured current state on `next`: `emailAndPassword.enabled: true` with no
|
|
||||||
gating — anyone who can reach the Gateway can create an account via
|
|
||||||
`POST /api/auth/sign-up/email` and receives role `member`.
|
|
||||||
|
|
||||||
Contract:
|
|
||||||
|
|
||||||
1. A single server-side setting `registration_mode` with values
|
|
||||||
`open | invite | closed`. It lives in the database (admin-mutable at
|
|
||||||
runtime), not in env config.
|
|
||||||
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
|
|
||||||
different mode during setup, recorded as an explicit operator choice.
|
|
||||||
While the bootstrap epoch is open (§3), the effective mode is `closed`
|
|
||||||
regardless of any stored value: the setting takes effect only after the
|
|
||||||
epoch completes.
|
|
||||||
3. `closed` blocks self-service email/password sign-up. It does not block
|
|
||||||
admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
|
|
||||||
gated by admin authorization alone — there is no separate switch for it.
|
|
||||||
JIT is gated by its per-provider flag (§4.1). All user-creating paths are
|
|
||||||
closed while the bootstrap epoch is open (§3).
|
|
||||||
4. `invite` requires a single-use, expiring invite token bound to an email
|
|
||||||
address. Invite issuance is an admin operation and is audit-logged.
|
|
||||||
5. Enforcement point: a better-auth hook (or equivalent middleware executed
|
|
||||||
inside the auth handler path), not a Gateway route guard in front of it —
|
|
||||||
the raw `/api/auth/*` handler must be incapable of bypassing the gate.
|
|
||||||
|
|
||||||
## 3. Bootstrap / first-admin invariant (from #1430)
|
|
||||||
|
|
||||||
Invariant: **the system transitions from zero users to one admin user exactly
|
|
||||||
once per bootstrap epoch, atomically, regardless of concurrency or which code
|
|
||||||
path writes users.**
|
|
||||||
|
|
||||||
Constraints any implementation MUST satisfy (each traces to a verified defect
|
|
||||||
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
|
|
||||||
|
|
||||||
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
|
|
||||||
lives in a constraint-backed one-row `bootstrap_state` table. While the
|
|
||||||
epoch is open, every non-bootstrap user-creating writer — better-auth
|
|
||||||
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
|
|
||||||
the writer's own path (better-auth hook for the raw handler; guard for
|
|
||||||
admin routes). A partial unique index or a winning epoch-transition row is
|
|
||||||
necessary but not sufficient on its own: neither stops an untagged insert
|
|
||||||
from a writer that never consulted the epoch. Both layers are required:
|
|
||||||
database-level transition safety (the epoch-completing write races safely
|
|
||||||
and at most one wins) and writer-level refusal (no path can create a user
|
|
||||||
without reading epoch state).
|
|
||||||
2. **Atomic first-admin transition.** The admin user, its credential account,
|
|
||||||
the initial admin token, and the epoch-completed transition commit in one
|
|
||||||
database transaction or not at all. A better-auth call through
|
|
||||||
`drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
|
|
||||||
transaction; it may be used inside the bootstrap transition only if the
|
|
||||||
adapter is explicitly bound to the transaction handle. Otherwise the
|
|
||||||
bootstrap writer must create the user rows itself within the transaction.
|
|
||||||
3. **Pool safety.** No design may hold a pooled connection inside a
|
|
||||||
transaction while awaiting a write that acquires a second connection from
|
|
||||||
the same pool (`DB_POOL_MAX=1` is a supported configuration).
|
|
||||||
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
|
|
||||||
epoch completes, re-running the wizard reconfigures the system but never
|
|
||||||
re-opens the zero-user transition. "Setup already completed" is a stable,
|
|
||||||
testable state, and factory-reset (a future, explicitly destructive
|
|
||||||
operation) is the only way to open a new epoch.
|
|
||||||
5. **No stranded partial outcome.** A failure at any point in the transition
|
|
||||||
leaves nothing observable (no admin user without its token, no completed
|
|
||||||
epoch without an admin) and setup remains retryable — this follows from
|
|
||||||
§3.2 and is stated separately because it is the pre-existing failure mode
|
|
||||||
the #1431 review verified.
|
|
||||||
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
|
|
||||||
initial user, the wizard executes the OIDC login as part of the bootstrap
|
|
||||||
transition itself: the bootstrap writer creates the account from the
|
|
||||||
asserted identity inside the §3.2 transaction. This path is the bootstrap
|
|
||||||
writer, not JIT — §4's JIT gate stays closed during the epoch and is not
|
|
||||||
an obstacle to D4.
|
|
||||||
|
|
||||||
## 4. JIT provisioning (OIDC first login)
|
|
||||||
|
|
||||||
1. A successful OIDC login with no matching account creates a user
|
|
||||||
just-in-time only when `jit_provisioning` is enabled for that provider.
|
|
||||||
The flag is per-provider and defaults off, always. There is no
|
|
||||||
mode-implied default: Enterprise setup enables JIT only when the wizard
|
|
||||||
records it as an explicit operator choice for a named provider (this
|
|
||||||
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
|
|
||||||
enabled", which contradicted the per-provider default).
|
|
||||||
2. JIT users receive platform role `member`, never an elevated role,
|
|
||||||
regardless of IdP claims (§5), and no workspace authority (§1.4).
|
|
||||||
3. An optional per-provider email-domain allowlist constrains JIT. The
|
|
||||||
allowlist matches only when the IdP asserts the email with
|
|
||||||
`email_verified: true`; an unverified address never satisfies the
|
|
||||||
allowlist. Empty allowlist with JIT on means any authenticated subject at
|
|
||||||
that IdP gets an account — permitted, but the wizard must present it as an
|
|
||||||
explicit choice.
|
|
||||||
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
|
|
||||||
SSO path is §3.6, not JIT.
|
|
||||||
|
|
||||||
## 5. Claim mapping
|
|
||||||
|
|
||||||
1. **Two stores, not one.** Provider-observed claims (`email`,
|
|
||||||
`email_verified`, display name, avatar) are recorded per external
|
|
||||||
principal — keyed by issuer + subject (§6.1) — at first login and
|
|
||||||
refreshed at each login. The canonical account fields (`users.email`,
|
|
||||||
`users.emailVerified`, `users.name`, `users.image`) are set exactly once
|
|
||||||
at account creation and are never silently overwritten by a later login.
|
|
||||||
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
|
|
||||||
is set from the provider's `email_verified` claim at creation; for
|
|
||||||
password-created accounts it is false until the address completes
|
|
||||||
verification.
|
|
||||||
2. **Canonical email changes only through an explicit workflow.** Either the
|
|
||||||
user-initiated email change (with verification of the new address) or an
|
|
||||||
admin edit. Any canonical email change — user- or admin-initiated — sets
|
|
||||||
`users.emailVerified` to false until the new address completes
|
|
||||||
verification; an admin may instead explicitly attest the address as
|
|
||||||
verified in the same operation, and that attestation is audit-logged. A
|
|
||||||
provider-claim refresh never rebinds `users.email` or
|
|
||||||
`users.emailVerified`; a divergence between canonical email and the latest
|
|
||||||
provider-observed email is surfaced per §6.4.
|
|
||||||
3. Never mapped from IdP claims: `role` and any future authorization
|
|
||||||
attribute. Authorization lives in the system of record and in the RBAC
|
|
||||||
layer (contract 2). An IdP group/role claim may at most be recorded for
|
|
||||||
audit; it grants nothing.
|
|
||||||
|
|
||||||
## 6. Account linking trust
|
|
||||||
|
|
||||||
1. **External principal identity is issuer + subject.** A linked identity is
|
|
||||||
keyed by the OIDC issuer and subject claims, not by an unqualified
|
|
||||||
provider subject id and not by email. The linked-identity row stores the
|
|
||||||
issuer, and the database enforces at most one local account per
|
|
||||||
**(issuer, subject)** with a unique constraint on those stored columns —
|
|
||||||
uniqueness on (provider, subject) is insufficient because provider →
|
|
||||||
issuer is not one-to-one: two provider configurations can point at the
|
|
||||||
same issuer, and the identity must not alias across them. The current
|
|
||||||
non-unique `(provider_id, account_id)` index satisfies neither;
|
|
||||||
application-level checks without a uniqueness witness lose
|
|
||||||
concurrent-callback races. Each configured provider additionally binds to
|
|
||||||
exactly one issuer, immutable after creation (changing the issuer means
|
|
||||||
creating a new provider).
|
|
||||||
2. Linking an OIDC identity to an existing account happens only in one of two
|
|
||||||
ways: (a) explicit link initiated by the logged-in user from settings,
|
|
||||||
which requires step-up: a fresh reauthentication (password or existing
|
|
||||||
linked method) no older than a short bound the implementation defines
|
|
||||||
(≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
|
|
||||||
session cannot quietly attach a durable login method; or (b) automatic
|
|
||||||
link when the IdP asserts a verified email exactly matching an existing
|
|
||||||
account **and** the provider is marked `trusted_for_linking`
|
|
||||||
(per-provider flag, default off).
|
|
||||||
3. Untrusted-provider email collision produces a login error naming the
|
|
||||||
conflict, not an auto-link and not a duplicate account.
|
|
||||||
4. A linked identity whose IdP-observed email later diverges from the
|
|
||||||
canonical account email keeps working (the link is by issuer + subject,
|
|
||||||
§6.1) but the divergence is surfaced in the user's settings and audit log
|
|
||||||
(the per-principal claim store in §5.1 is what makes the divergence
|
|
||||||
representable).
|
|
||||||
5. Unlinking a login method is refused when it would leave the account with
|
|
||||||
no **usable** login method. Usable means: a set password, or a linked
|
|
||||||
identity whose provider is currently configured and enabled on this
|
|
||||||
deployment. A linked identity whose provider has been removed or disabled
|
|
||||||
(§1.2) is not usable and does not count; setting a password first lifts
|
|
||||||
the refusal.
|
|
||||||
|
|
||||||
## 7. Deactivation propagation
|
|
||||||
|
|
||||||
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
|
|
||||||
Concretely:
|
|
||||||
- Ban and session revocation are one operation: the ban commit revokes all
|
|
||||||
better-auth sessions for the user. If revocation partially fails, the
|
|
||||||
ban itself must already be committed and every guard denies from that
|
|
||||||
point (fail closed); the operation is retryable.
|
|
||||||
- Every authenticated entry path checks banned state: HTTP session guards,
|
|
||||||
the admin bearer-token path (which today does not test `banned` — an
|
|
||||||
implementation defect this contract makes non-conformant), MCP, and
|
|
||||||
Socket.IO.
|
|
||||||
- Active socket connections are terminated or denied within 30 seconds of
|
|
||||||
the ban commit, or at the next inbound message on that socket, whichever
|
|
||||||
comes first (socket auth at connect-time only, as today, does not
|
|
||||||
satisfy this).
|
|
||||||
- The current admin ban route updates only the user row; it does not
|
|
||||||
conform to this section until revocation and guard coverage land.
|
|
||||||
- Admin tokens owned by the banned user are revoked in the same operation.
|
|
||||||
2. Deactivation at an external IdP does not propagate automatically in this
|
|
||||||
contract's scope (no SCIM). Operational rule: removing a user from the IdP
|
|
||||||
without banning them in Mosaic leaves any password or other linked login
|
|
||||||
method usable — the admin guide must state this. SCIM/webhook-driven
|
|
||||||
propagation is future work and out of scope here.
|
|
||||||
3. **Deletion is not deactivation, and deletion is gated here.** Account
|
|
||||||
deletion semantics (FK fan-out across the 21 foreign-key constraints to
|
|
||||||
`users.id`, spread over 19 referencing tables) require their own
|
|
||||||
deletion-and-retention contract, chartered as an addition to the S2 list —
|
|
||||||
contract 7 is the D14 sensitive-data custody contract and does not cover
|
|
||||||
account deletion. Until that deletion contract is ratified: the existing
|
|
||||||
hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
|
|
||||||
closed, and deactivation is the only supported removal operation. A
|
|
||||||
contract that merely declared deactivation "the only supported removal"
|
|
||||||
while the endpoint stayed live would be false on its face.
|
|
||||||
Disabling the endpoint alone is insufficient — its shipped callers must
|
|
||||||
not be left as advertised operations that now fail generically:
|
|
||||||
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
|
|
||||||
and any SPA port of it) is removed, or replaced by a disabled control
|
|
||||||
whose visible text states that deletion is unavailable pending the
|
|
||||||
deletion-and-retention contract and points at deactivation.
|
|
||||||
- The CLI command `mosaic auth users delete`
|
|
||||||
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
|
|
||||||
with a message stating the same and naming the deactivation command.
|
|
||||||
- Both surfaces expose deactivation as the supported operation.
|
|
||||||
|
|
||||||
## 8. Verification requirements
|
|
||||||
|
|
||||||
Every MUST above needs a bounded observable. The matrix:
|
|
||||||
|
|
||||||
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
|
|
||||||
distinct physical connections (pattern:
|
|
||||||
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
|
|
||||||
which runs in the `test` CI step against the `ci-postgres` PostgreSQL
|
|
||||||
service — note that pattern multiplexes one pooled handle, so the tests
|
|
||||||
here must explicitly open separate connections). Races to cover:
|
|
||||||
setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
|
|
||||||
Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
|
|
||||||
the transition (user, credential, token, epoch) proving nothing observable
|
|
||||||
leaks and setup retries; wizard re-run after completion proving the
|
|
||||||
zero-user transition never re-opens. Mocked-transaction specs are
|
|
||||||
supplementary; they cannot prove serialization.
|
|
||||||
2. **Registration gating (§2).** Spec coverage of all three modes against the
|
|
||||||
raw `/api/auth/` handler path, not only Gateway controllers; invite
|
|
||||||
lifecycle (single-use, expiry, email binding); effective-`closed` while
|
|
||||||
the epoch is open regardless of stored mode.
|
|
||||||
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
|
|
||||||
account with platform role `member` and no workspace grant; domain
|
|
||||||
allowlist rejects an unverified email claim even when the domain matches;
|
|
||||||
JIT refused while the epoch is open.
|
|
||||||
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
|
|
||||||
store and touches none of the canonical fields (`users.email`,
|
|
||||||
`users.emailVerified`, name, image); explicit email-change workflow is the
|
|
||||||
only path that rebinds canonical email; every canonical email change
|
|
||||||
resets `users.emailVerified` to false unless the admin attestation path
|
|
||||||
is taken, and that attestation appears in the audit log.
|
|
||||||
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
|
|
||||||
callbacks for the same (issuer, subject) yield exactly one account, and
|
|
||||||
two provider configurations sharing one issuer cannot create two accounts
|
|
||||||
for the same subject; trusted auto-link; untrusted collision error;
|
|
||||||
step-up both ways: an explicit link succeeds immediately after a fresh
|
|
||||||
reauthentication and is refused once the implementation's chosen bound
|
|
||||||
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
|
|
||||||
unlink refusal when no remaining method is usable per §6.5, including the
|
|
||||||
removed-provider case, and acceptance after a password is set; divergence
|
|
||||||
surfaced after IdP email change.
|
|
||||||
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
|
|
||||||
(partial-failure injection); guard denial post-ban on each transport:
|
|
||||||
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
|
|
||||||
within the 30-second/next-message bound; banned user's admin tokens
|
|
||||||
unusable; hard-delete endpoint returns a fail-closed error while the
|
|
||||||
deletion contract is unratified; the admin web UI renders no live delete
|
|
||||||
action (absent, or disabled with the §7.3 text) and `mosaic auth users
|
|
||||||
delete` exits non-zero with the §7.3 message — both asserted by spec.
|
|
||||||
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
|
|
||||||
deleting a provider configuration leaves every user row intact and every
|
|
||||||
other login method working (spec over the provider-config removal path).
|
|
||||||
Forward-auth non-use: with in-app OIDC configured, a request carrying
|
|
||||||
forward-auth identity headers and no session is treated as anonymous —
|
|
||||||
no code path derives identity from those headers (negative spec at the
|
|
||||||
Gateway entry). First-admin SSO: the §3.6 transition commits account,
|
|
||||||
token, and epoch atomically from the asserted identity, and fault
|
|
||||||
injection mid-transition leaves nothing observable (same harness as §8.1).
|
|
||||||
Wizard-recorded JIT choice: enabling JIT for a provider writes an
|
|
||||||
explicit per-provider operator-choice record, and no mode selection
|
|
||||||
enables it implicitly (assert the stored record, not UI behavior).
|
|
||||||
8. **Documentation observable (§7.2).** The admin guide contains the
|
|
||||||
IdP-removal-does-not-deactivate statement; verified by a docs assertion
|
|
||||||
(content check in CI or an enumerated review-checklist item on the
|
|
||||||
implementing PR) — a MUST about documentation needs a checkable artifact,
|
|
||||||
not intent.
|
|
||||||
|
|
||||||
## Ruling request
|
|
||||||
|
|
||||||
Ratify sections 1–8 as written, with one decision embedded: registration
|
|
||||||
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
|
|
||||||
you want as the default.
|
|
||||||
Reference in New Issue
Block a user