Skip to content

feat(contracts/module): a role's installation_owned_scopes - #839

Merged
antoinetoussaint-byte merged 2 commits into
mainfrom
feat/role-installation-owned-scopes
Oct 26, 2026
Merged

antoinetoussaint-byte merged 2 commits into
mainfrom
feat/role-installation-owned-scopes

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Refresh against main (2026-10-26)

Merged current main without rebasing. Refreshed head: b13bccb1718836c18e165685df54135343cf9ac5. No PR has been merged.

go vet ./... passed. The full local go test -race ./... -timeout 10m passed every package except two pre-existing runners/golang workspace tests: the required GOFLAGS=-mod=mod is inherited by their nested Go workspace commands, where Go refuses that flag. Contract, solution, solutionhost and CI-guard race tests passed. GitHub CI runs independently; its current result is recorded on this head.

What we are trying to do

Let a solution declare, in its module contract, a role scope that the authority limits to what the installation owns. codefly-dev/authority#7 adds RoleV1.installation_owned_scopes (field 3) and resolves each such scope per installation into the same scope limited to the ids that installation owns of the kind (never the whole kind). Core's contract Role had only name, scopes and bindings, so nothing in YAML could reach that field.

The change

modulecontract.Role gains InstallationOwnedScopes []CeilingScope (yaml:"installation_owned_scopes"):

roles:
  - name: viewer
    scopes:
      - {resource_kind: wiki.page, actions: [read]}
    installation_owned_scopes:
      - {resource_kind: documents.collection, actions: [read]}

Validation mirrors the authority PR's semantics and reuses core's existing role rules (no new rule names). Each refusal says "installation-owned":

Authority (#7) Core here
names no ids CeilingScope has no id field, so resource_ids is refused as an unknown field (known-fields)
held to the same ceiling as scopes role-within-request: within the unit's own ceilings or a scope a binding spells out for invoke or lookup
needs a declared role structural: the list sits inside a role
a role with neither scopes nor owned scopes grants nothing role-grants: a role may grant through owned scopes alone
— role-kind-name, role-kind-unique (per list; a kind may appear in both lists with its own actions), role-kind-has-action, action-name/action-unique
a module declares none not decidable here: the contract does not know whether the unit is a module or a solution. The authority refuses it at delivery, the same as roles

Resolve addresses each installation-owned scope the same way it addresses a literal role scope, into the new ResolvedRole.InstallationOwnedGrants (nil when the role declares none). It refuses one that two audiences underwrite, naming both. The addressing loop is shared, not duplicated.

Fixtures: one accepted (roles-installation-owned) and four refused (role-installation-owned-ids, -beyond-consumption, -kind-without-action, -kind-named-twice). The kit goes from 125 to 130 documents; the documented count and the pinned name list are updated. docs/module-contract.md documents the field.

There is no proto: the module contract has none in core, so nothing is regenerated.

Reuse

  • Reused: CeilingScope, the existing role rules (extended through one Role.scopeLists() helper), actionLists, grantable, and the underwriters addressing in resolveRoles.
  • Not built: a second scope type with an ids field, or new rule names.

Classification

This is a fix at the owner: core's module contract is where a unit's roles are declared, and the CLI carries them from there.

Verification

  • go test ./contracts/... ./solution/... ./solutionhost/... ./internal/ciguard/... and go vet on those packages pass locally.
  • Mutation check: I made scopeLists return scopes only, and the three owned-scope refusal fixtures failed. Restoring it made them pass.
  • Not verified locally: the full go test ./..., -race, the sandbox/nix-tagged suite and coverage thresholds. CI runs those.

Follow-ups

🤖 Generated with Claude Code

A role can now grant a scope only on what the installation it is held on
owns, mirroring the authority's RoleV1.installation_owned_scopes (field 3,
codefly-dev/authority#7):

  roles:
    - name: viewer
      scopes: [...]
      installation_owned_scopes:
        - {resource_kind: documents.collection, actions: [read]}

Each entry is a kind and actions and never ids (CeilingScope has no field
for them, so resource_ids is refused as an unknown field). It is held to
every rule a role scope is, under the same rule names with
"installation-owned" in the message: kind and action grammar, each kind once
per list, at least one action, within the unit's own ceilings or a scope a
binding spells out for invoke or lookup. A role may grant through it alone.
Resolve addresses it like a literal scope, into
ResolvedRole.InstallationOwnedGrants, and refuses one two audiences
underwrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 590c256 into main Oct 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant