Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 21 additions & 15 deletions docs/work-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,18 @@ because the unknown-field refusal that used to reject the whole token no longer
fires. Until such a reader compares owners it must refuse, or grant nothing
from, any scope with a non-empty `resource_owner_id`, and an issuer must not
mint an owner-selected scope to its audience. The readers known to inspect
scopes directly, read at each repository's main on 2026-10-25 (reviews r109
and r128). It is the list known so far, not a proof that no other exists:
scopes directly, read at each repository's main on 2026-10-25 (reviews r109,
r128 and r142; the authority at d091cc7a). It is the list known so far, not a
proof that no other exists. The first three rows are owner-aware at authority
main since #18 (f688c549); a consumer pinned before that commit still has the
owner-blind version:

| Reader | Where | Owner-blind because |
| --- | --- | --- |
| authority hop library (Go) | `libraries/hop/go/hop.go` (`flatten`) | stamps `kind:action` for every scope without ids, so `Identity.Has` widens; authority #18 (open) skips owner-selected scopes |
| authority client (Python) | `authority_client/work_context.py` (`Scope`, `scope_contained`) | the `Scope` tuple drops the owner and containment ignores it; authority #18 (open) keeps the owner |
| authority hop (Python) | `codefly_hop` (`_flatten`) | same flattening as the Go hop; changed in authority #18 (open) |
| authority gateway issuer | `modules/authority/services/gateway/code/pkg/business/issuer/issuer.go:98-118`, `state.go:1156` | a held scope with no ids sets `whole`, which returns every grant (or the human owner's) for the kind |
| authority hop library (Go) | `libraries/hop/go/hop.go:183` (`flatten`) | owner-aware since f688c549: skips an owner-selected scope, so `Identity.Has` never reads it as the whole kind. Before it, stamped `kind:action` for every scope without ids |
| authority client (Python) | `authority_client/work_context.py:113` (`Scope`), `:218` (`scope_contained`) | owner-aware since f688c549: `Scope` keeps `resource_owner_id` and containment compares it. Before it, the tuple dropped the owner |
| authority hop (Python) | `codefly_hop/__init__.py:232` (`_flatten`) | owner-aware since f688c549: skips an owner-selected scope, as the Go hop does |
| authority issuer, `ListResources` | `modules/authority/services/local/code/pkg/business/issuer/issuer.go:88-126` (`whole` at `:103-104`), `state.go:1346` (`coverageOf`, whole kind at `:1359`) | owner-blind: a held scope with no ids sets `whole`, which returns every grant (or the human owner's) for the kind. Unreachable with an owner-selected scope while the issuer mints none (below) |
| module-annotations | `pkg/infra/workcontext/authority.go` | reads kind, actions and ids field by field |
| module-evaluation | `identity.go` | reads kind, actions and ids field by field |
| module-record-store | `adapters/operations.go` | reads kind, actions and ids field by field |
Expand All @@ -136,12 +139,13 @@ and r128). It is the list known so far, not a proof that no other exists:
| cli execution recorder | `pkg/executionrecorder/work_context_authority.go` | reads kind, actions and ids field by field |

The authority's `Claims.Allows` builds on `ScopeContained` and fails closed,
but its issuers do not use it: the gateway issuer above reads scopes itself.
Authority #18 (open, not merged when this was written) makes the local issuer
refuse, by name, an issue request or a delegation whose scopes name a resource
owner ("this authority issues no owner-selected scope yet"). That refusal
covers what the local issuer is asked to mint; it does not make the gateway
issuer's resource-grant path owner-aware.
but its issuer does not use it. The authority has one service, `local`, and
one `Issuer`. Since #18 (f688c549) its `Issue` (`issuer.go:205`) and
`Delegate` (`:662`) refuse, by name, scopes that name a resource owner
(`refuseOwners`, `:750`: "this authority issues no owner-selected scope
yet"). Its `ListResources` still reads scopes itself and is owner-blind; it
can meet an owner-selected scope only once the issuer mints one, so it must be
made owner-aware before that.

Runnable operations never carry an owner: not in the owner's declaration, not
in an installation's binding, not in a composition's scope-slot selection —
Expand All @@ -152,9 +156,11 @@ through `workcontext.InheritResourceOwner(requested, parent.EffectiveScopes())`
and mints the child with the result: a scope the parent covers as written is
kept, one the parent covers only under an owner gets that owner, and it is
refused — never guessed — when no owner covers it, when several do (mint one
child per owner), when only several owners' rows together cover it (split it
by owner; the refusal says so rather than "not covered"), or when the scope
already names another owner. An inherited owner applies to the whole scope:
child per owner), when only several owners' rows together cover it (the
refusal says so rather than "not covered", and advises one invocation per
owner only when the owners split it by resource; when they split its actions
no child can hold the whole scope, and the parent must hold it under one
owner), or when the scope already names another owner. An inherited owner applies to the whole scope:
under the parent `[doc:read, doc:write@P]` the operation scope
`doc:{read,write}` becomes `doc:{read,write}@P`, so the child reads only P's
documents although the parent reads all of them. That is intended and fails
Expand Down
11 changes: 9 additions & 2 deletions workcontext/owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,19 @@ func TestInheritResourceOwner(t *testing.T) {
"ids covered only by two owners together": {
requested: rows(scope("doc", []string{"read"}, []string{"a", "b"})),
parent: rows(owned("doc", []string{"read"}, []string{"a"}, "P"), owned("doc", []string{"read"}, []string{"b"}, "Q")),
refusal: `owner-scoped parent: scope "doc" is covered only across resource owners ["P" "Q"] together`,
refusal: `owner-scoped parent: scope "doc" is covered only across resource owners ["P" "Q"] together, never under one; each of its resources is covered under one owner, so invoke it once per owner`,
},
"actions covered only by two owners together": {
requested: rows(scope("doc", []string{"read", "write"}, nil)),
parent: rows(owned("doc", []string{"read"}, nil, "P"), owned("doc", []string{"write"}, nil, "Q")),
refusal: `covered only across resource owners ["P" "Q"] together`,
refusal: `covered only across resource owners ["P" "Q"] together, never under one; they split its actions, so no child can hold the whole scope`,
},
// Ids named, but the owners split the actions on each: no per-owner
// invocation could hold the operation's whole scope.
"named ids whose actions two owners split": {
requested: rows(scope("doc", []string{"read", "write"}, []string{"a"})),
parent: rows(owned("doc", []string{"read"}, []string{"a"}, "P"), owned("doc", []string{"write"}, []string{"a"}, "Q")),
refusal: "they split its actions",
},
"an id no owner covers beside ids two owners do": {
requested: rows(scope("doc", []string{"read"}, []string{"a", "c"})),
Expand Down
36 changes: 34 additions & 2 deletions workcontext/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func ScopesAttenuate(child, parents []*basev0.WorkScopeV1) bool {
// closed, with ErrInvalid naming the reason, when no owner covers the scope
// (the parent does not hold it), when several do (the runtime must mint one
// child per owner rather than have one chosen for it), when only several
// owners' rows together cover it (split it by owner), and when a requested
// owners' rows together cover it, and when a requested
// scope already names an owner the parent does not cover it under — an owner
// someone wrote is never replaced. The result is a fresh copy; requested and
// parent are only read, and the result attenuates parent.
Expand Down Expand Up @@ -129,7 +129,10 @@ func InheritResourceOwner(requested, parent []*basev0.WorkScopeV1) ([]*basev0.Wo
case 0:
child.ResourceOwnerId = ""
if owners := coveringOwners(child, parent); len(owners) > 1 {
return nil, fmt.Errorf("%w: owner-scoped parent: scope %q is covered only across resource owners %q together, never under one; split it into one child per owner", ErrInvalid, child.GetResourceKind(), owners)
if splitsByResource(child, parent) {
return nil, fmt.Errorf("%w: owner-scoped parent: scope %q is covered only across resource owners %q together, never under one; each of its resources is covered under one owner, so invoke it once per owner on that owner's resources", ErrInvalid, child.GetResourceKind(), owners)
}
return nil, fmt.Errorf("%w: owner-scoped parent: scope %q is covered only across resource owners %q together, never under one; they split its actions, so no child can hold the whole scope and the parent must hold it under one owner", ErrInvalid, child.GetResourceKind(), owners)
}
return nil, fmt.Errorf("%w: scope %q is not covered by the parent, with or without a resource owner", ErrInvalid, child.GetResourceKind())
case 1:
Expand Down Expand Up @@ -183,3 +186,32 @@ func coveringOwners(child *basev0.WorkScopeV1, parent []*basev0.WorkScopeV1) []s
}
return owners
}

// splitsByResource reports whether a scope that only several owners cover
// together is split between them by resource id: every one of its ids is
// covered, with all of the scope's actions, under a single owner. Then the
// operation can run once per owner on that owner's resources. A scope naming
// no ids, or one whose actions are split between owners, has no such split:
// every child would lack an action the operation's one declared scope needs.
func splitsByResource(child *basev0.WorkScopeV1, parent []*basev0.WorkScopeV1) bool {
if len(child.GetResourceIds()) == 0 {
return false
}
for _, id := range child.GetResourceIds() {
point := &basev0.WorkScopeV1{ResourceKind: child.GetResourceKind(), Actions: child.GetActions(), ResourceIds: []string{id}}
covered := false
for _, row := range parent {
if owner := row.GetResourceOwnerId(); owner != "" && row.GetResourceKind() == child.GetResourceKind() {
point.ResourceOwnerId = owner
if ScopeContained(point, parent) {
covered = true
break
}
}
}
if !covered {
return false
}
}
return true
}
4 changes: 4 additions & 0 deletions workcontext/testdata/resource_owner_vectors.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"_comment": "resource_owner_id vectors: every protovalidate implementation must accept the first list and refuse the second. Read by workcontext/owner_test.go.",
"accepted": [
{
"name": "no owner",
"owner": ""
},
{
"name": "512 ASCII bytes",
"owner": "u",
Expand Down
Loading