# DD — Local Party and Project views (#757) [Common design](dd.md) · [Tasks and completion status](adr.md) · [Integration plan #750](https://github.com/pharos-ai-factory/pharos-platform/issues/750). Status: design note. It lists the member and Project facts Metallo's execution contexts need, compares them with what Beto publishes on the governance branch at `89abcde5e`, and records the decisions taken in #754 and #755 and the gaps that remain. #791 delivered #754's contracts; #780 implements the views, their consumers, and their load and repair from Beto's checkpoints. ## What Metallo asks today Every member ID Metallo handles is the Pharos Party ID: the API supplies the signed-in Party as the request actor, and Metallo takes it as the member ID without any sign-in mapping of its own. Every Project ID is the canonical Project ID. The production compositions refuse to start until these owners are connected ([`adaptors/external_owners.py`](../../../adaptors/external_owners.py)). | Port | Facts needed | Used by | Owner | | --- | --- | --- | --- | | [`ExecutionAuthority.permission(member, project, capability)`](../../../contexts/execution_core/application/execution_access.py) | Is this Party currently an active member of this Project, with a role that may execute? | Launch acceptance (3B), cancellation (7C), the Run reads and action launches of #724 | Beto Project | | [`WorkloadAuthority.permission`](../../../contexts/workload_portfolio/application/ports.py) | The same | Workload create, revise and read; the action catalogue of #724 | Beto Project | | [`PipelineAuthority.permission`](../../../contexts/pipeline_coordination/application/ports.py) | The same | Pipeline start, read and cancellation request | Beto Project | | [`RuntimePermissions.check(member, project)`](../../../contexts/site_runtime/application/ports.py) | The same, fresh, before each native start and private read, without platform PostgreSQL | Site Runtime | Beto Project, delivered through the Site protocol | | Party eligibility (no port yet) | Is the Party enabled and eligible to authenticate? Disablement must stop new work. | All of the above | Beto Party Governance | | [`ProvisioningAccounts.accounts_for(member, project)`](../../../contexts/execution_core/application/accounts.py) and [`MemberBindings.lookup`](../../../contexts/site_runtime/application/ports.py) | The member's provisioned account at the Site: reference, namespace and ServiceAccount | Run Supervisor (1B), Site Runtime (5A) | Resource/Site owner (#763, #765); correlates through Beto's bindings | | [`ProvisioningResources.resources_for(project)`](../../../contexts/execution_core/application/resource_lookup.py) and `RetryAdmission.check(run)` | Applied limits, readiness and admission | Planning (3A, 3C), retry (4E) | Resource owner (#764) | | `RepositoryDirectory`, `RepositoryActions` (#724, 2D) | Repository path to Project and repository IDs; `actions.yaml` | Requests, Workload creation | Source Control (#758, #759) | Only the first five rows are #757's. The last three are listed so that their owners see the same canonical IDs; they are not Beto facts. ## What Beto publishes On `feat/beto-party-project-governance` at `89abcde5e`: - [`ProjectChanged`][events-project]: a complete snapshot of one Project at a revision: name, lifecycle, management generation, the Waldur binding (`provider`, `instance`, `external_id`, `scope`) and the complete membership list, each member as `party_id`, `roles` (`project-administrator`, `developer`), binding ID/revision and external user ID. Never truncated; a source read above 256 members is refused. - [`ProjectAccessChanged`][events-project]: Project ID, revision and the affected Party IDs, including removed Parties. It may share a revision with `ProjectChanged`. - [`PartyAuthenticationChanged`][events-party]: Party ID, auth version, `access_state` (`enabled`/`disabled`) and `authentication_eligible`. `PartyRegistered`, `PartyProviderBindingEstablished/Retired` and `PartyIdentityAttributesChanged` exist too. `PartyProviderBindingRetired` matters for access: Beto's `CheckProjectCapability` also requires the membership's exact provider binding to remain established at its recorded revision (below). - Queries ([Project][queries-project], [Party][queries-party]): `GetProjectIntegrationSnapshot(project_id)` returns the same snapshot plus the source status (`state`, `last_complete_read_at`, `valid_until`); `CheckProjectCapability(project_id, party_id, capability)` decides the operation's explicit Project capability with that source status and the Party's auth version; `ResolveProjectProviderBinding` maps a Waldur project to the Project ID; `CheckPartyAuthentication` gives one Party's state. - Metallo is already a declared consumer of both query contracts ([catalogue][beto-catalogue]), so their Python bindings are generated into `metallo.adaptors.generated`. No Metallo broker identity may send them yet: the only declared caller is `pharos_api` ([Party broker][broker-party]). #791 then added what this note asked of #754: the paginated `ListProjectIntegrationSnapshots` inventory and the `GetPartyIntegrationSnapshots` selection, both read under immutable 24-hour checkpoints with exact item and manifest hashes; the `ProjectSynchronisationCheckpointPublished` and `PartySynchronisationCheckpointPublished` markers; and one enrolled caller per Metallo context (`pharos__views`) with seven durable queues, the binding facts included. [Project contract](../../../../../contracts/project/messaging/README.md), [Party contract](../../../../../contracts/party_governance/messaging/README.md). ## Coverage and gaps | Need | Covered by | Decision or gap, and where it goes | | --- | --- | --- | | Membership and roles per Project | `ProjectChanged` | None for the mapping itself. | | Removal of a member | `ProjectAccessChanged`, then the next `ProjectChanged` | Agreed (#755): new execution is refused once the removal is applied. Accepted Runs continue and other authorised members manage them; cancelling them is a separate decision for authorised members or operators. | | Party disablement | `PartyAuthenticationChanged` | Agreed (#755): new execution is refused once applied. Running work is not cancelled automatically. | | Retirement of the membership's provider binding | `PartyProviderBindingEstablished` and `PartyProviderBindingRetired`, each at the binding's own revision (#791) | Resolved (#754): each view keeps every binding, and a membership counts only through an established binding at the revision the Project recorded, as `CheckProjectCapability` decides. | | Initial load and rebuild of all Projects | `ListProjectIntegrationSnapshots` under one checkpoint (#791) | Agreed (#754) and implemented in #780. | | Initial load of Party states | `GetPartyIntegrationSnapshots`, up to 256 Parties under one checkpoint (#791) | Agreed (#754) and implemented in #780: each Party is loaded when it first appears in a Project; an unknown Party stays pending until its state is available. | | Freshness of an access decision | `ProjectAuthorityChanged` carries independently revisioned state and absolute validity; queries return the same owner evidence | Agreed (#755): consumers prove material and authority progress, repair divergence and fail closed during outage/rebuild. Default end-to-end revocation ceiling is 125 seconds; configured maximum is 305 seconds. | | Capability names | Beto defines `project.view`, exact `runs.*`, `workloads.*`, `pipelines.*` and `project.access.manage`; legacy aliases are transitional | Agreed (#755): every Metallo current-person operation asks its exact capability. | | Broker enrolment of Metallo consumers | `external_consumers` admits a Metallo subscription only with one dedicated caller per Metallo context and Beto owner | Agreed (#754) and declared in #791: `pharos__views` for `project` and `party_governance`, with the queues from [`subscriptions.json`](../../../contracts/subscriptions.json). | | Permission facts at the Site | Nothing reaches Site Runtime today | Design with #765 and #766 (below). | ## Proposed views Each consuming context owns its view, in its own database with its own credentials, as #757 requires: Execution Core, Workload Portfolio and Pipeline Coordination each keep one. The facts are small and stable, so three copies cost little and no context reads another's tables. The delivery code is shared: the [`consume`](../../../adaptors/delivery.py) loop Pipeline Coordination already uses for `run.completed`, with its quorum, retry and dead-letter queues. Tables per view, all keyed by canonical IDs: - `project_views(project_id, revision, name, lifecycle, management_generation, provider, instance, external_id, scope, content_hash)` and `project_members(project_id, binding_id, party_id, roles, binding_revision, external_user_id)`, replaced whole on each `ProjectChanged`, one member row per qualified Party binding; `party_views(party_id, revision, auth_version, access_state, authentication_eligible)`; `party_bindings(binding_id, party_id, revision, state, provider, instance, external_id, retirement_reason)`. - The shared consumer receipts, committed with the change, so a redelivered event is acknowledged without a second effect. `ProjectChanged` and `ProjectAccessChanged` for one revision have separate receipts, and both reactions run. - `view_state(ready, loaded_at)`: a view is not ready until its initial load has matched Beto's checkpoints, and until then the port raises `AuthorityUnavailable`, which every handler already treats as "defer and retry". `view_checkpoints` keeps, per owner, the newest announced checkpoint and the newest one the view matched. - `project_access_invalidations(project_id, party_id, revision)`: a `ProjectAccessChanged` newer than the saved snapshot records its revision for each affected Party. Ordering: a `ProjectChanged` or `ProjectAccessChanged` at or below the saved Project revision is a no-op, a `PartyAuthenticationChanged` at or below the saved Party root revision (the envelope's) likewise, and a binding fact at or below the binding's saved revision too. An access change that runs ahead of its snapshot fails closed for the affected Parties: their memberships are removed, and they stay denied until a complete snapshot reaches the recorded revision, which clears the invalidation. A delayed older snapshot therefore cannot restore a removed member, and an access change that arrives before the first snapshot is kept rather than discarded. The snapshot revision itself is never advanced by an access change, because that would suppress the complete snapshot at the same revision. Order between Projects does not matter. Initial load and rebuild, as agreed in #754: subscribe first, then page Beto's Project inventory under one checkpoint for the context's enrolled source scope, and ask one Party checkpoint for every Party those Projects name, 256 at a time. Each item is applied unless the view already holds it or something newer; a differing item at the same revision is repaired from the owner. The view then rebuilds Beto's manifests from its own rows and is marked ready only when both match exactly: newer local state means a later checkpoint is compared instead. A Party Beto does not know yet stays pending and is asked for again. A lost database repeats the same steps. Synchronisation and repair, as agreed in #754 and #755: Beto publishes progress checkpoints covering the relevant changes, and each view records the checkpoint it has fully applied, keeping failed or outstanding deliveries visible. Each consumer starts its own checks through Beto's published interfaces: a daily comparison of the agreed shared fields (IDs, revisions and content hashes, including removals) at a consistent checkpoint, where counts alone are not enough. A difference is repaired from the owner's snapshots; a complete rebuild is kept for the initial load and for larger inconsistencies. Processes: each view runs in the process that holds its context's enrolled credential (#791): Execution Core's in the execution worker, Pipeline Coordination's in the coordinator and Workload Portfolio's in `metallo`. The first two install paused today and start once their owners are connected (#766, #771); `metallo-requests` reads the two views it needs from their databases. ### Deciding with the view The view answers mapping and membership: who is a member of which Project, with which roles, and whether a Party is enabled. That serves correlation and replaces the per-request owner queries #724 would otherwise make. It decides by `CheckProjectCapability`'s rule: an active Project, an executing role, an enabled and eligible Party, and a membership whose provider binding is established at the revision the Project recorded, for the same provider account. A Party or binding the view has not received yet defers the decision. What the view cannot hold is the source's freshness, which only Beto's query evidence carries. A current-person read, launch, manual retry or cancellation also needs current evidence. Every allowing decision calls `CheckProjectCapability` with its exact capability and honours its `source_status.valid_until`; a `stale_authority` or unavailable answer defers the work with that reason, as #755 agreed. Its callers are the three `permission` ports above. The calls stay after the views are synchronised: #791's checkpoints supply identity and access-state evidence, not current Project permission, so the execution path keeps the owner check. The Run access contract (#755) uses explicit Project permissions held by the person asking, independent of whether the Run's initiator is still a member. They are `project.view`, `runs.view` (listing a Run, its status, initiator and usage), `runs.output.view` (logs and published results) and `runs.cancel`. Logs and results can contain repository content, so granting them shares that content with the Project's members; basic Run visibility does not. Access to the repository and to private workspace files keeps its own rules. The contract also defines `runs.launch`, `runs.retry` and `runs.cancel`. ### Site Runtime Site Runtime has no platform PostgreSQL. Its `RuntimePermissions.check` and `MemberBindings.lookup` are answered over the Site protocol in one of two ways: the Site API gains two authenticated read operations, answered from Execution Core's view and evidence and from the native-account owner, or each start command carries the decision with its validity and the Runtime enforces the bound locally. Neither applies a removal instantly. A Site API answered from an asynchronous view lags by the view's propagation delay, and a carried decision holds until its validity ends. Both need the agreed maximum delay for applying an access removal, and both refuse new starts and private reads while the platform cannot confirm synchronisation. The second keeps the Runtime independent of the platform during execution. Decide in #765 and #766 together with the native account facts. Leaving a Project suspends personal access while accepted work may finish (#755). Other authorised members inspect, collect or cancel it under their own identities. The agreed Site direction retains the exact native account binding for that accepted work (#765), including automatic retries and later declared Pipeline steps. Final removal follows natural completion or authorised cancellation and cleanup. Beto's Project-specific suspension facts, Execution Core's retention and release publication, and Site enforcement remain activation work. The [Run access policy](run-access.md#retained-native-identity-and-operation-authority) defines the owners, native JWT gap and cross-Project isolation requirements. ## Decisions from #754 and #755 The target agreed on 23 October 2026: 1. Initial load and rebuild use a paginated canonical Project inventory and the Project integration snapshots. Each Party is loaded when it first appears in a Project; an unknown Party stays pending. Subscriptions start before the load and keep the changes that arrive during it. 2. One dedicated caller per Metallo context and Beto owner, with separate permissions, queues and local persistence. 3. Material checkpoints and independently revisioned authority transitions establish synchronisation and freshness. Until both are applied locally, every allow calls `CheckProjectCapability`. 4. Run, Workload and Pipeline operations use their explicit capability, held by the person asking. 5. Removing a member or disabling a Party refuses new execution once applied. Accepted Runs continue when their initiator leaves, and other authorised members manage them. Cancelling existing Runs stays a separate decision for authorised members or operators, for example during a security incident. #791 delivered the Project inventory, the checkpoint events and the caller enrolment; the Run permissions remain. Its handover also keeps Beto's authority queries in the execution path once the views are synchronised, so the call in decision 3 stays rather than ending with the synchronisation. ## Still open - The timeout for missing progress and the maximum delay for applying an access removal (#755). - What a member may see in a namespace-wide list when repository visibility is narrower than Project membership (#755, #750 decision 3). - The final Run permission names and what each exposes (#755). - Execution authority that outlives the initiator: Run repository tokens (#762), Site accounts (#765), and the authority for retries and later Pipeline steps (#766). ## Implementation (#780) Each context's eight consumers come from the generated subscription registry and run in the process named for them in [`services/subscriptions.json`](../../../services/subscriptions.json): the project, access and authority facts, the authentication and both binding facts, and the two checkpoint markers. A marker only records the newest announced checkpoint and wakes the synchroniser; it never proves a match by itself. The synchroniser (`adaptors/party_project_sync.py`) runs beside the consumers. It loads the view for the source scope in `PROJECT_SOURCE_SCOPE`, a JSON object with the provider, instance and scope Beto enrolled the context for. It reads Beto through the context's own `*_VIEWS_BROKER_URL` identity, on reply queues named for that context's family, and checks every page and selection, including each item's hash, before using it. The view keeps every field of Beto's canonical Project, memberships in Beto's order, and rebuilds each item hash from its own rows when it compares, as it does for Parties, so no copied hash vouches for a row: a membership lost or changed locally cannot match a checkpoint, and the checkpoint's item repairs it. An item at the view's own revision repairs Project, Party or binding content that differs; newer facts are kept. It compares again with each announced checkpoint and with a fresh one daily, and asks again for pending Parties. Without `PROJECT_SOURCE_SCOPE` the consumers still apply every event, but the view never becomes ready, so its decisions stay unavailable. The dev-box overlay enrols all three processes: the Waldur registration that enrols Beto's callers also writes the provider, instance and customer to `metallo-project-source`, a Secret holding only that scope, which `metallo`, the execution worker and the pipeline coordinator mount beside their own credentials and read through `PROJECT_SOURCE_SCOPE_FILE`. One registration enrols both sides, so the scope cannot drift from Beto's. With the dev box's Waldur import off, the views load an empty list. The item and manifest hashes follow Beto's encoders exactly: a Project's item hash is taken over the `CanonicalProject` fields that `ProjectChanged` shares, so an event and a checkpoint item compare directly, and a Party's is rebuilt from the view's authentication and binding rows. The unit tests pin digests computed by Beto's own Go encoder. ### Project authority (#798) `ProjectAuthorityChanged` carries each Project's authority at its own revision: ready with one complete source read and its absolute `valid_until`, or pending or failed. Beto renews it on every routine read, so a view keeps only the latest revision per Project in `pharos.project_authority`, with a fingerprint of its content ([`adaptors/project_authority.py`](../../../adaptors/project_authority.py)). Each delivery commits the row alone, without a consumer receipt: an older revision is ignored, a redelivery finds its own revision, and an equal revision with other content is corrupt and goes to the dead-letter queue. The synchroniser applies each checkpoint item's `source_status` by the same rule, so a newer event is never replaced. The query reports ready evidence past its validity as `stale`; that is the same revision, so it is kept as ready, and its expiry still denies. Every decision the view takes first checks that Project's authority: it must be ready, unexpired, not ahead of this clock by more than five seconds, and describe the Project revision the view holds. Otherwise the view may be behind Beto, so the decision is deferred, a refusal included. A Project the view holds nothing about is refused as before. Expiry is checked on every decision, so a stopped consumer or an empty queue denies within the validity window. Renewals older than the queues' 305-second TTL are dropped; they could not authorise anything. Current-person operations also need Beto's current evidence (#755). #794 composes each view with `CheckProjectCapability` in the execution worker and the coordinator: the view answers first, and Beto confirms every allowing exact-capability decision. `metallo-requests` reads Execution Core's and Workload Portfolio's views over their databases and confirms with its own query-only caller, `pharos_metallo_requests_views`, which takes no event queues. [Run access](run-access.md). Verification: the event lane applies Beto-shaped facts to Execution Core's copy through the enrolled `pharos_execution_core_views` identity and the queues Beto's provisioning declares, including removals around late snapshots, binding retirement, markers for another scope and lost acknowledgements. Authority revisions replace older ones there without receipts, pending authority and a material change it has not described yet defer decisions, and an equal revision with other content is refused. A second lane loads and repairs the view over the real broker from a Beto stand-in that answers both owner queues. The other two contexts apply the same facts through their migrations, grants and port adaptors. ## Out of scope LLM Access (#769), SSH Access (#768) and Support (#770) apply the same contracts in their own contexts. Resource limits (#764), native accounts (#765) and repository resolution (#759) are other owners' facts; this note only fixes the IDs they share. [events-project]: https://github.com/pharos-ai-factory/pharos-platform/blob/89abcde5e6c4b46cc407a684056d9fb4296c65fc/contracts/project/messaging/proto/pharos/project/v1/events.proto [events-party]: https://github.com/pharos-ai-factory/pharos-platform/blob/89abcde5e6c4b46cc407a684056d9fb4296c65fc/contracts/party_governance/messaging/proto/pharos/party_governance/v1/events.proto [queries-project]: https://github.com/pharos-ai-factory/pharos-platform/blob/89abcde5e6c4b46cc407a684056d9fb4296c65fc/contracts/project/messaging/proto/pharos/project/v1/queries.proto [queries-party]: https://github.com/pharos-ai-factory/pharos-platform/blob/89abcde5e6c4b46cc407a684056d9fb4296c65fc/contracts/party_governance/messaging/proto/pharos/party_governance/v1/queries.proto [beto-catalogue]: https://github.com/pharos-ai-factory/pharos-platform/blob/89abcde5e6c4b46cc407a684056d9fb4296c65fc/contracts/beto/messaging/catalogue.json [broker-party]: https://github.com/pharos-ai-factory/pharos-platform/blob/89abcde5e6c4b46cc407a684056d9fb4296c65fc/contracts/party_governance/messaging/broker.json