Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
17c0622
fix(frontend): validate the generated plugin allowlist at load, confi…
antoinetoussaint-byte Oct 13, 2026
029342c
fix(accounts,frontend): a declared trigger names only a delegation it…
antoinetoussaint-byte Oct 13, 2026
0d0b0b4
feat(accounts,frontend): name, bound and expose the module identity m…
antoinetoussaint-byte Oct 13, 2026
03c5bb6
test(accounts): pin the moved page's derived access boundary in the r…
antoinetoussaint-byte Oct 13, 2026
1ab2695
Merge remote-tracking branch 'origin/main' into issue-1121-follow-ups…
antoinetoussaint-byte Oct 13, 2026
39ac774
chore(frontend): move the two kit packages whose published bytes changed
antoinetoussaint-byte Oct 13, 2026
52cde7c
fix(frontend): move the reference composition's kit pins with the pac…
antoinetoussaint-byte Oct 13, 2026
eafdbd4
style(accounts): goimports' alignment on the struct the sweep switch …
antoinetoussaint-byte Oct 13, 2026
5c4ef93
fix(frontend,docs): the identity page obeys the read-outcome rules, a…
antoinetoussaint-byte Oct 13, 2026
2cda10b
fix(accounts,frontend): a self-addressed delegation is a candidate, n…
antoinetoussaint-byte Oct 13, 2026
120a6c7
fix(frontend): the schedules panel reports this host's sweep for ever…
antoinetoussaint-byte Oct 13, 2026
d2d3810
docs(accounts): the backfill and the declaration say self-addressed h…
antoinetoussaint-byte Oct 13, 2026
b3d01c3
fix(frontend): one source's row classifies its own declaration read, …
antoinetoussaint-byte Oct 13, 2026
6d331a1
test(frontend): close every declaration read state, and say so in the…
antoinetoussaint-byte Oct 13, 2026
90733da
fix(frontend): a refused read deletes the key it was refused, and no …
antoinetoussaint-byte Oct 13, 2026
678d810
fix(frontend): a superseded refusal is stale news, and a pending decl…
antoinetoussaint-byte Oct 13, 2026
e62ebd1
fix(frontend): the supersession guard counts generations, not millise…
antoinetoussaint-byte Oct 13, 2026
08273d3
test(frontend): the deletion holds on the two lifecycle paths the tes…
antoinetoussaint-byte Oct 13, 2026
95b6259
fix(frontend): a paused read has not answered, in all three views tha…
antoinetoussaint-byte Oct 13, 2026
174b6ad
fix(frontend): the children card and the occurrence button stop asser…
antoinetoussaint-byte Oct 13, 2026
62e007e
fix(frontend): "has this read answered?" is asked once, in the read seam
antoinetoussaint-byte Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAIM_INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ must not be inferred from a row here. See
| A reconcile capability is bound to the source whose DELEGATION it carries, not just to a scoped one | WORK_CONTEXTS.md § Triggers | `TestAChildFromOneSourcesDelegationIsRefusedOnAnother` (`pkg/adapters`) mints a real capability whose binding scopes two sources, presents it through `authenticateSourceOperationContext`, and asserts the delegation's own source is admitted and the other refused; red under both mutations (dropping the gate, and re-discarding the delegation) | implemented | accounts |
| One occurrence's enqueue key is scoped by organization and source | accounts/AGENTS.md § A datasource source is refreshed by three things | `TestReconcileOccurrenceKeyIsScopedByOrgAndSource` (`pkg/business`): two organizations' sources under one caller-chosen effect identity produce different keys, each key names all three parts, and two attempts of one occurrence still agree | implemented | accounts |
| `DelegateSourceTriggers` is additive and refuses another member's personal source | WORK_CONTEXTS.md § Triggers | `TestDelegateSourceTriggersLeavesAnotherModulesDelegationAlone`, `TestDelegateSourceTriggersRefusesAnotherMembersPersonalSource` (`pkg/business`) assert the delegation rows: another module's survives active and owner-intact, nothing is revoked, and the owner's own call records | implemented | accounts |
| The backfill records a reference only for a SELF-ADDRESSED target, and replaces that holder's unusable one | accounts/AGENTS.md § Unattended work | `TestDelegateSourceTriggersLeavesAnotherModulesDelegationAlone` asserts a target addressed to another service gets nothing; `TestDelegateSourceTriggersReplacesTheSelfAddressedHoldersStaleDelegation` asserts a stale-digest delegation is revoked as `binding_changed` and replaced while a current one is left untouched. Both red under the matching mutation (dropping the self-addressed filter; always skipping an existing row). It narrows who may hold a CANDIDATE and establishes nothing about who executes the release — see the row below | implemented | accounts |
| A declared trigger's reference arm names only a SELF-ADDRESSED delegation, and reports whose it is | accounts/AGENTS.md § Unattended work; WORK_CONTEXTS.md § Triggers | `TestTriggerDeclarationNamesOnlyADelegationItsExecutorCanUse` (`pkg/business`) removes each of the three facts on its own — addressed to another module, a binding no longer declared, an authority that did not resolve — and asserts no identity is named; `TestTriggerDeclarationNamesTheHolderAndClaimsNoExecutor` varies the HOLDER independently (A-only, B-only, B revoked, B's binding changed) and pins the reported holder; `TestTriggerDeclarationProjectionCarriesEveryField` (`pkg/adapters`) holds every field to the wire | implemented | accounts |
| That the module which EXECUTES a declared release holds the reference | accounts/AGENTS.md § Unattended work; WORK_CONTEXTS.md § Triggers | none, and none is possible from here (#1121, F1): nothing this host holds declares who executes declared work, so a self-addressed delegation identifies a candidate and its holder, never the executor. A composed executor with no delegation on the source is refused at the mint. The read reports the holder and leaves readiness unverified | not claimed | accounts; needs a contract naming the executing principal |
| A refused reconcile pass enqueues nothing | accounts/AGENTS.md § A datasource source is refreshed by three things | `TestReconcileSourceOperation_RefusedPassEnqueuesNothing` (`pkg/business`): an effect identity re-presented for another source, and one re-presented with another request digest, are both refused with no job for them anywhere; red when the pre-check is removed | implemented | accounts |
| The stopgap sweep can be switched off, and the reader is told which schedule fires and when | accounts/AGENTS.md § Unattended work | `TestTheStopgapSweepCanBeSwitchedOff` (`pkg/business`) asserts the default is ON, that a disabled sweep enqueues nothing and bumps nothing, and that the declaration's `host_sweep_due_at` is the STORED instant and differs from the cron's; `source-trigger-schedules.container.test.tsx` asserts both rows render and that the host's row says "off" with no instant | implemented | accounts, frontend |
| `source.synced` says whether anybody was present for the pass | accounts/AGENTS.md § Unattended work | the adapter marks a delegated pass (`business.WithUnattendedPass`) and the observation records `unattended` and the delegation id, declared optional in payload v2. The actor stays the delegating person, which ADR 0008 puts in the trail deliberately | implemented | accounts |
| The declaration carries the field names the executing module validates | MODULE.md § Unattended work; WORK_CONTEXTS.md § Triggers | `TestTriggerDeclarationMatchesTheContractFieldNames`, `TestTriggerDeclarationUsesDeclaredPolicyValues` (`pkg/business`) | implemented | accounts |
| Only intervals a five-field cron can name are declarable; the rest are refused by name | accounts/AGENTS.md § A datasource source is refreshed by three things | `TestTriggerScheduleSpellsOnlyTheIntervalsCronCanName` (`pkg/business`) | implemented | accounts |
| An occurrence under the reference arm is minted from current facts, and the next one after a revocation is refused | MODULE.md § Unattended work; WORK_CONTEXTS.md § Triggers | `TestTriggerOccurrenceMintsFromTheReferenceAndIsRefusedAfterRevocation` (`pkg/adapters`) | implemented | accounts |
Expand All @@ -109,6 +115,9 @@ must not be inferred from a row here. See
| No surface in this repository reaches a source sync without an effect identity | MODULE.md § Unattended work; saas-ui README § Datasources | `reconcile-is-the-only-manual-pass.test.ts` walks the TypeScript AST of `src` and the published packages and fails on any `syncSource` on a datasource client | implemented | frontend |
| A person's pass is listed beside the declared schedule's occurrences, with its effect identity | MODULE.md § Unattended work | `source-trigger-schedules.container.test.tsx` asserts the manual row is in the SAME `admissions` table as the two scheduled rows, and that a recovered receipt replaces its row rather than adding one | implemented | frontend |
| Every pass a source ever took, listed | — | none, and none is possible here: a receipt is recovered by identity and never enumerated, so the panel shows only the passes this browser admitted and says so | not claimed | accounts |
| A module identity minted for ONE tenant, bounded by that tenant's own installation | accounts/AGENTS.md § A module identity minted for one tenant; WORK_CONTEXTS.md § A module identity minted for one tenant | `TestSharedInstallationMintSelectsOnlyConsentingOrganization` (`pkg/business`) mints for two organizations and compares what each capability carries; `TestResolveModuleTenantIdentityAnswersWhatOneTenantsMintWouldAssert` and `TestResolveModuleTenantIdentityRefusesATenantThePinForbids` hold the operator read to the same four answers the mint gives | implemented | accounts |
| An operator can read what a module's per-tenant capability would carry, without one being issued | accounts/AGENTS.md § A module identity minted for one tenant | `module-tenant-identity-playground.container.test.tsx` records every request the page makes and asserts it is the resolution alone; the authorization table pins `ResolveModuleTenantIdentity` at `super_admin` | implemented | accounts, frontend |
| A module selecting the per-tenant mint through its published client | WORK_CONTEXTS.md § A module identity minted for one tenant | `saas-sdk-go/moduleauthority.Credentials.OrganizationID` is present in v0.1.0 (and v0.1.1/v0.1.2); each client fixes one organization and both mint request bodies carry it. NOT exercised from this repository: `qualification/module-authority` pins `v0.0.3-0.20261011073356-ac003d53e6b1`, whose mints take no organization, so the per-tenant path is proved at this host's own seam. A composed caller must still supply the tenant and the installed operation binding | implemented (client) / unexercised here | module-authority client; this repo's qualification pin; composed consumer proof remains open |
| A DECLARED schedule actually coming due, in a composed environment | MODULE.md § Unattended work ("what is not proven") | none — nothing in this repository composes a module that executes declared work, and the module that fires triggers keys one installed trigger per release name while this host declares every source under one | planned | #1081 |

## Handbook and interface surfaces
Expand Down
21 changes: 19 additions & 2 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,23 @@ arm is planned and has never been emitted.
The depth is in
[services/accounts/AGENTS.md](./module/services/accounts/AGENTS.md#unattended-work-whose-authority-an-occurrence-runs-under)
and [WORK_CONTEXTS.md](./module/WORK_CONTEXTS.md#triggers-for-work-no-person-started);
the playground is `/admin/platform/unattended-work`.
the playground is `/admin/unattended-work`.

**A module identity minted for one tenant.** Beside the trigger identities
above, a module acting with no person present can present itself as acting
*for* a tenant rather than as itself: the headless operation mint names the
organization, and the capability's tenant is that organization, its scopes that
organization's installation narrowed to the binding's `headless_scopes`. Which
organizations it may name is the envelope's (a pinned principal names its own
tenant and no other; an unpinned one declaring `cross_tenant` names one per
mint), and each is then held to its own consent — so the bound is per tenant,
not per deployment. That is what a consumer admitting a publication for a tenant
reads the publisher's tenant from.
`PlatformAdminService/ResolveModuleTenantIdentity` and
`/admin/platform/module-identity` answer what one mint would assert, or why it
would be refused, and issue nothing. The existing client selects one tenant
through `Credentials.OrganizationID`; its operation-mint wiring is described in
[WORK_CONTEXTS.md](./module/WORK_CONTEXTS.md#a-module-identity-minted-for-one-tenant).

## The host as an OAuth 2.1 authorization server

Expand Down Expand Up @@ -655,7 +671,8 @@ Every page lives under `frontend/code/src/app/admin/`:
| `/admin/billing` | `OpenBillingPortal`, `ListInvoices` |
| `/admin/platform/admins` | platform role grant/revoke |
| `/admin/platform/organizations` | `ListAllOrganizations`, `GetOrganizationRoster`, `CreateOrganization` (with `owner_user_id`), `UpdateOrganization`, `AddMember`, `RemoveMember`, `DeleteOrganization` |
| `/admin/platform/unattended-work` | `ListSources`, `GetSourceTriggerDeclaration`, `DelegateSourceTriggers`, `RevokeSourceDelegation`, `ReconcileSource` — the playground for whose authority work no person started runs under |
| `/admin/unattended-work` | `ListSources`, `GetSourceTriggerDeclaration`, `DelegateSourceTriggers`, `RevokeSourceDelegation`, `ReconcileSource` — the playground for whose authority work no person started runs under |
| `/admin/platform/module-identity` | `ResolveModuleTenantIdentity` — what a module's capability for ONE tenant would carry, or why it would be refused. It issues no capability |

Client-side gating uses `<RoleGate>` (`src/components/auth/role-gate.tsx`) — display-only; backend remains authoritative.

Expand Down
96 changes: 94 additions & 2 deletions module/WORK_CONTEXTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,8 @@ The host mints a child Work Context with:
- owner = the module's own service principal (kind `service`), acting directly:
no actor hop, as core models an owner acting, with the seal naming its
installation and the build its pod runs;
- tenant = the tenant the module envelope's grant declares for that principal;
- tenant = **the organization the capability acts FOR**, which the caller names
and the envelope decides (see below);
- a 60-second lifetime and the idempotent replay policy;
- an authorization revision that is a digest of the module's whole derived
registry entry (`business.ModuleOperationContextRevision`, never zero).
Expand Down Expand Up @@ -532,6 +533,77 @@ A composed module reaches it through the gateway's `POST
/modules/_operation-context` ([services/auth-gateway/AGENTS.md](./services/auth-gateway/AGENTS.md)),
which brokers to accounts exactly as `/modules/_work-context` does.

### A module identity minted for ONE tenant

This mint **is** the per-tenant module identity, and it is worth naming rather
than leaving as a parameter nobody documents (issue #1121). `organization_id` on
the request is the organization the capability acts for: its tenant is that
organization, so a module can present itself as *acting for a tenant* instead of
as itself. That is what a consumer admitting a publication for a tenant needs
from a publisher with nobody signed in — it takes the tenant from the verified
capability, where this host has already decided the principal may speak for it,
never from a request field it would have to trust.

**Which organizations may be named is the envelope's, never the caller's.**
`moduleMintOrganization` decides it, identically for the identity mint and this
one:

| The envelope declares | It may mint for |
| --- | --- |
| `tenant: <org>` | that organization only; another is `PermissionDenied`. `cross_tenant` beside a pin changes nothing |
| no tenant, `cross_tenant: true` | one organization per mint, named on the request |
| no tenant, no `cross_tenant` | nothing: naming an organization is not authority over it |

**And every named organization is then held to its own consent.** The mint
re-reads the binding *after* `authorizeModuleCapabilityFromSnapshot` intersects
that organization's installation scope, so what a per-tenant capability carries
is that tenant's consent and not the deployment's: an organization that
consented to fewer headless scopes gets fewer, and one that consented to none
leaves the binding with no headless scopes and is refused. The bound is
therefore per tenant, which a single deployment-wide identity could not be.
Proved by `TestSharedInstallationMintSelectsOnlyConsentingOrganization`, which
mints for two organizations and compares what each capability carries.

**Reading it back, and the playground.**
`PlatformAdminService/ResolveModuleTenantIdentity` answers what one mint would
assert for a module, a binding and an organization — the principal, the tenant,
the audience, the scopes, the lifetime, the revision, and whether the principal
is shared — or the refusal, in one of four forms, because what an operator does
about each is different: declare the principal, widen the envelope's tenancy,
install the module there, or consent to the binding's headless scopes there.
Beside it it answers the trail of capabilities actually issued, which is the
only record of one. `/admin/platform/module-identity` drives it.

**It issues no capability, and that is the shape rather than a limitation.** The
mint authenticates with the module pod's projected ServiceAccount token held
against the build its authority document approves; a browser has none, and a
surface that answered an operator with a module's capability would be a second
spelling of that module's identity. So the read resolves the decision from the
same declared facts and stops before the signature.

**Selecting a tenant through the published client.**
`saas-sdk-go/moduleauthority` already exposes `Credentials.OrganizationID`
(including v0.1.0). Construct a separate client for each organization: both
`ModuleWorkContext` and `MintModuleOperationContext` send that selector. For an
outbound operation use `MintModuleOperationContext` with the installed binding;
`ModuleWorkContext` addresses the host's capability broker and is not authority
to call a different audience. The operation result names its tenant and
principal; a publisher must hold that tenant to its committed change and use
that principal as its source. This is consumer wiring, not a missing mint.

**What this repository does not exercise.** `qualification/module-authority`
still pins `v0.0.3-0.20261011073356-ac003d53e6b1`, a pre-0.1.0 pseudo-version
whose mints take no organization at all — so the per-tenant mint is proved
against this host's own seam and not through the published client. Raising that
pin, with a qualification case that mints for two organizations through it, is
the gap that remains here.

The operator page and its issued-capability trail distinguish a read not yet
requested, loading, a failed read and a successful empty result. A forbidden
read clears retained identity snapshots, so later refresh failures cannot
redisplay authority the viewer was refused. Transient failures retain a prior
successful answer with a stale-data notice.

## Operation contexts from a source delegation

A datasource source's sync is neither of the cases above. It runs as a module
Expand Down Expand Up @@ -764,6 +836,26 @@ observe — hence `TestTriggerDeclarationMatchesTheContractFieldNames`, which
pins the document's names *and values* and records the other column beside
them.

**What the host does about a schedule is reported separately from what the
schedule says, for every source.** The stopgap sweep reconciles an active source
whose stored instant has elapsed, whether or not that source declares a trigger
— an interval no five-field cron can express is undeclarable and still swept —
so the declaration read carries `host_sweep_enabled` and `host_sweep_due_at`
beside the cron for the undeclarable case as well. And a sweep that is switched
off says only that this host stopped: whether a module consumes the declaration
is not something this host verifies, so neither the read nor the operator view
asserts it.

**A declared reference is a candidate, not a readiness claim** (#1121, F1). The
host sets the reference arm for a self-addressed delegation — the only shape a
source-id reference can serve — and reports whose delegation it is
(`identity_holder`). It does **not** establish that the module which executes
this release holds one: nothing the host has declares who that is, those
bindings are generic, and a deployment may have several. A composed executor
with no delegation on the source is refused at the mint
(`ErrSourceDelegationMissing`), so the read names the holder and leaves executor
readiness unverified rather than calling the source bound.

**Exactly one identity, and the two are not alternatives of equal standing.**

- `source` — **the grant reference, and the contract.** It names a
Expand Down Expand Up @@ -849,7 +941,7 @@ against them. The children are read from `saas.datasource.delegation.used`,
because that trail is the only record of one — a capability lives about a minute
and nothing caches it — so a revoked reference shows up as the next occurrence
having no child at all. The playground that drives the whole walk is
`/admin/platform/unattended-work`.
`/admin/unattended-work`.

**What the reconcile trigger's occurrences admit, and the scope that binds
them.** `DatasourceService/ReconcileSource` and `LookupReconcileSource` are
Expand Down
Loading
Loading