Skip to content

WASM-safe PermScope + AccessScope value types and deterministic v1:/v1z: packed perm codec #42

Description

@wangkanai

WASM-safe PermScope + AccessScope value types and deterministic v1:/v1z: packed perm codec

Filing target: github.com/wangkanai/federation (or a newly-claimed Wangkanai.Federation.Claims shared claims package) — Feature request, Priority P1 (per research
009 §8
P1 codec row).

Motivated by: research
agile/research/009-refactoring-shared-federation-domain-from-account/report.md
§8 P1 codec row. Drafted from epic
031-oss-contribution-parallel-track/031-04-p1-scope-primitives-deterministic-codec.

Status of siblings: P0 (license consistency, 031-01) is Filed upstream per
the tracker. P1 scoped-Permission / RolePermission (031-02) and
P1 multi-tenant Tenant / (email, tenant) (031-03) are the parallel siblings in this series.
This issue is the codec half of the P1 work — value types + a deterministic packed
perm codec suitable for offline / WASM consumers. Upstream AccessScope / PermScope
/ AppKey entitlement shapes (cross-app grant matrices, per-app role resolution) are
explicitly deferred to the 031-05 P2 row; this P1 ask is JUST the codec + value type
primitives, nothing more.


Problem statement

A federated identity-claims codec that needs to be both deterministic (byte-stable across
re-encodes, so cache keys, CI snapshots, and bug reports reproduce) and WASM-safe (no DI,
no I/O, no async, runs identically on the server claims-issuance path and on a Blazor
WebAssembly offline decoder) does not currently ship in any reusable form. Every consumer
of wangkanai/federation who needs this property either re-implements the codec from
scratch or reaches for a non-federation dependency — both of which fracture the wire
contract across consumers and block offline-first deployments.

RiverSync ships a production implementation of exactly this primitive in
Common/src/Claims/PermClaimCodec.cs and a sibling value-type
Account/src/Application/Authorization/AccessScope.cs whose semantics are aligned with
epic 029's generalization plan (see 029-03-U1). We are asking upstream to expose
WASM-safe pure primitives (no DI / no I/O) plus a deterministic packed perm codec
with the contracts the next section pins, so that:

  1. The Field offline path (Blazor WebAssembly / IndexedDB) does not have to take a
    hard dependency on RiverSync's Common.Claims to decode a federated perm claim
    (it currently reuses our codec, not upstream).
  2. A cross-consumer wire contract stabilizes: any IdP-issued token in the
    Wangkanai.Federation ecosystem carrying a perm claim decodes to the same
    (App, Perm, Scope) triples in every consumer, online and offline.
  3. The narrowing semantics of AccessScope.Intersect (only-narrowing, returns null
    on incompatible) become reusable across consumers instead of every consumer
    re-deriving the truth table.

Concrete evidence — what RiverSync ships today

Artifact Lines What it pins
Common/src/Claims/PermClaimCodec.cs 7-27 (header) FR-001..FR-006 + WASM-safe rationale
Common/src/Claims/PermClaimCodec.cs 39-77 (PermScope) Org-wide / Region / Site factories + inline form
Common/src/Claims/PermClaimCodec.cs 92-172 (PermScopeMap) IReadOnlyList, order-INVARIANT equality, EntryComparer
Common/src/Claims/PermClaimCodec.cs 192, 195-196 OverageThresholdBytes = 1024 (≤ 2048); PlainPrefix = "v1:", OveragePrefix = "v1z:"
Common/src/Claims/PermClaimCodec.cs 255-295 (EncodePlainCore) deterministic lexicographic sort + group-by-app + inline scope
Common/src/Claims/PermClaimCodec.cs 297-310 (EncodeOverage) gzip + base64url overage tier
Common/src/Claims/PermClaimCodec.cs 312-346 (base64url helpers) +→-, /→_, = stripped; ArrayPool<char>
Common/src/Claims/PermClaimCodec.cs 358-386 (Decode) v1: / v1z: dispatch + hard error on unknown marker
Common/src/Claims/PermClaimCodec.cs 388-465 (DecodePlainCore) segment walker + app-boundary disambiguation
Common/test/Unit/Claims/PermClaimCodecTests.cs 60-82 byte-exact worked fixture: v1:admin:manage|portal:edit@region:rid|view
Common/test/Unit/Claims/PermClaimCodecTests.cs 144-191 worst-case persona (200 entries, threshold logic)
Common/test/Unit/Claims/PermClaimCodecTests.cs 197-215 overage marker test: past-threshold ALWAYS carries v1z:
Common/test/Unit/Claims/PermClaimCodecTests.cs 269-287 forward-compat: v1: decodes after a future v2: ships
Account/src/Application/Authorization/AccessScope.cs 28-30 abstract record + private ctor (closed set by design)
Account/src/Application/Authorization/AccessScope.cs 38, 48, 58 OrgWide / Region(Id) / Site(Id) sealed nested records
Account/src/Application/Authorization/AccessScope.cs 64-81 resolver-only construction (factories)
Account/src/Application/Authorization/AccessScope.cs 83-125 Intersect truth table (only-narrowing, null on incompatible)
Account/src/Application/Authorization/AccessScope.cs 126-149 identity element + same-kind collapse + Region∩Site → null
Account/src/Application/Authorization/AccessScope.cs 109-117 Region ∩ Site purity note (resolver owns the site.RegionId lookup)
Field/src/Client/Auth/FieldAccountClaimsPrincipalFactory.cs 5-33 header comment — both responsibilities, codec-as-shared-contract anchor
Field/src/Client/Auth/FieldAccountClaimsPrincipalFactory.cs 53 class declaration, Blazor WASM hook
Field/src/Client/Auth/FieldAccountClaimsPrincipalFactory.cs 86-100 non-null AuthenticationType guard (the "always-authenticated" pitfall)
Field/src/Client/Auth/FieldAccountClaimsPrincipalFactory.cs 134-178 compiled-claim decoder, the shared PermClaimCodec.Decode call site
Field/src/Client/Auth/FieldAccountClaimsPrincipalFactory.cs 169-177 the decode step itself, one permission claim per (App, Perm, Scope) triple
Field/src/Client/Auth/OfflineAuthenticationStateProvider.cs 7-13 header cell matrix (Live / StaleButAuthorized / Anonymous / Anonymous+clear)
Field/src/Client/Auth/OfflineAuthenticationStateProvider.cs 22-24 job pillar #1 — "never re-implement the codec"
Field/src/Client/Auth/OfflineAuthenticationStateProvider.cs 251-258 offline decoded triple shape mirrors online (same App:Perm@ScopeKind[:ScopeId])
Field/test/Unit/Auth/FieldPrincipalFactoryTests.cs 24, 66, 97, 132, 145, 168 direct PermClaimCodec.Encode / EncodeWithOverage / Decode calls
Field/test/E2E/OfflineAuthFlowTests.cs 277 FieldAccountClaimsPrincipalFactory contract (real WASM host)
Common/src/Web/ViewAsContext.cs 53-59 never-throw invariant (per-render MainLayout, malformed token must not log out)
Common/src/Web/ViewAsContext.cs 67-68 POCO-not-DI rationale (consumable shape, not a service)
Common/src/Web/ViewAsContext.cs 79, 96, 103, 111, 120 class + read-only properties (IsViewAs, ActorSubject, SessionId, WriteIsViewAs)
Common/src/Web/ViewAsContext.cs 143-158 both-claims-required / all-or-nothing enforcement
Common/test/Unit/Web/ViewAsContextTests.cs 59, 87-115, 125-145 consumable-shape proof — the analog precedent this issue asks to mirror
agile/epics/029-common-oss-foundation-layer/029-03-generalized-scope-claim-primitives/spec.md:5 internal generalization to Common.Domain (Status IMPLEMENTED)
agile/epics/029-common-oss-foundation-layer/029-03-generalized-scope-claim-primitives/spec.md:38-43 truth-table + null + round-trip pins (mirrors the upstream ask)
agile/research/009-refactoring-shared-federation-domain-from-account/report.md §8 P1 codec row line 113 research anchor for the P1 framing

Request

We respectfully ask the wangkanai/federation maintainers (or a newly-claimed shared
claims package — see §Scope below) to ship the following three coordinated primitives
so the WASM/offline consumer pattern is consumable from upstream packages, not just
from RiverSync's Common library.

1. A PermScope value type (or a Common.Claims equivalent)

  • Shape: readonly partial record struct PermScope(PermScopeKind Kind, string? Id)
    with three cases:
    • OrgWide (no Id, no inline suffix on the wire)
    • Region(string id) (inline form @region:<id>)
    • Site(string id) (inline form @site:<id>)
  • Type system enforces the closed set — analogous to
    AccessScope.cs:28-30 (abstract record + private ctor + sealed nested records).
  • Allocation-aware: factory methods (OrgWideScoped(), RegionScoped(id),
    SiteScoped(id)) and a shared OrgWideInstance singleton for the common case.
  • Equality is structural — two PermScopes are equal iff Kind matches and
    (when Kind != OrgWide) Id matches; this is the precondition for the codec's
    PermScopeMap order-INVARIANT set-equality.

2. A PermScopeMap (the {permission → scope} map the codec operates on)

  • Shape: sealed class PermScopeMap : IReadOnlyList<PermEntry>, IEquatable<PermScopeMap>
    where PermEntry(App, Perm, Scope) is the atomic unit. Mirrors
    Common/src/Claims/PermClaimCodec.cs:92-172 (the EntryComparer SortedSet<PermEntry>
    backing).
  • Order-INVARIANT equalityDecode(Encode(x)) == x holds regardless of original
    insertion order, so duplicate (App, Perm) keys with different scopes are
    distinguished as a multiset.
  • An Empty sentinel — round-trips to a v1:-marked empty wire form (FR-006
    edge case in RiverSync's codec, asserted by Encode_EmptyMap_RoundTripsToEmpty).

3. A deterministic PermClaimCodec (pure, DI-free, WASM-safe)

  • Surface: a static class with Encode(PermScopeMap) → string,
    Decode(string) → PermScopeMap, and the same v1: / v1z: wire format RiverSync ships.
    No constructor, no instance state, no service-locator.
  • Wire format (FR-002 / FR-005 in RiverSync's codec):
    • Plain marker v1: — entries grouped by app, pipe | separates both app
      boundaries AND perms within a group. First segment is <app>:<perm><scope>;
      subsequent segments are |<perm><scope> (in-app) OR |<app>:<perm><scope> (new app).
    • Overage marker v1z: — gzip+base64url form, triggered when plain UTF-8 byte
      length EXCEEDS OverageThresholdBytes (RiverSync uses 1024; the upstream
      contract should keep ≤ 2048 so the encrypted token stays under the 4 KB
      JWE budget).
    • Base64url with +→-, /→_, = padding stripped; uses ArrayPool<char> to
      avoid per-decode allocation.
    • Hard error on unknown markerDecode NEVER silently ignores a non-v1: /
      non-v1z: prefix; it throws (RiverSync pins this with
      Decode_UnknownMarker_Throws).
    • Forward-compat — a v1: fixture decodes after a future v2: ships; the
      v1: version marker is what makes this property testable.
  • Determinism contract (FR-002 in RiverSync's codec): two encodes of the same
    logical map MUST yield byte-identical output. Sort keys in order:
    App (Ordinal) → Perm (Ordinal) → Scope.Kind (byte ordinal) → Scope.Id
    (null → empty, Ordinal). The worked fixture RiverSync pins is
    v1:admin:manage|portal:edit@region:rid|view for
    { (admin, manage): OrgWide, (portal, edit): Region("rid"), (portal, view): OrgWide }
    byte-exact, asserted by Encode_WorkedFixture_ProducesByteExactV1String.
  • All-or-nothing decode — modeled on
    Common/src/Web/ViewAsContext.cs:143-158 (the 018 cross-tenant precedent):
    the packed value either decodes to a complete (App, Perm, Scope) set, or yields
    an empty set, with deterministic behavior for the malformed case. A
    per-render WASM consumer cannot afford to throw on a corrupt token any more than
    MainLayout can; the upstream decoder should degrade malformed input to "no
    permissions" the same way 018 degrades malformed act JSON to "no session".
  • WASM-safe by construction — pure input → pure output, no IServiceProvider,
    no async dispose, no I/O. This is the property that makes the Field offline path
    work and the same property RiverSync asks upstream to commit to (POCO, not DI,
    in 018 terms).

4. An AccessScope value type with only-narrowing Intersect (deferred-to-P2

recommendation, NOT this issue)

This P1 ask does NOT request the full AccessScope / PermScope entitlement shape
(the closed-set value type with OrgWide / Region / Site cases and a
non-widening Intersect static method). That work is explicitly deferred to the
031-05 P2 row in research 009 §8 — it sits on the same architectural pattern
(Account/src/Application/Authorization/AccessScope.cs:28-149) and will be the
subject of a separate filing. The P1 codec is the precondition for the P2 value
type to be consumable in a wire-stable way, and we are sequencing accordingly.

Scope of the ask (explicit)

  • In scope: PermScope value type, PermScopeMap, PermClaimCodec (encode +
    decode + plain + overage tiers), a worked-fixture + round-trip test suite, a
    documented OverageThresholdBytes constant with a documented ceiling.
  • Out of scope (deferred to 031-05 P2 row): full AccessScope discriminated
    type with OrgWide / Region / Site cases and only-narrowing Intersect,
    per-app role / entitlement resolution (RolePermission.Allowed, AppKey
    matrix), cross-app grant shapes (ResolvedAccess).
  • Out of scope (deferred to 031-02 P1 sibling): scoped Permission /
    RolePermission models with allowed-scope narrowing (separate filing in the
    parallel sibling epic 031-02).
  • Out of scope (deferred to 031-03 P1 sibling): multi-tenant Tenant +
    (email, tenant) composite identity helpers + scalar-FK ApplicationUser-like
    base (separate filing in the parallel sibling epic 031-03).
  • No timeline promise, no code contribution, no licensing-philosophy request
    (per the same explicit no-promises framing as our P0 filing).

Where this should ship

Either as additions to wangkanai/federation itself, or as a new
Wangkanai.Federation.Claims (or similarly-named) shared claims package —
whichever scope the maintainers prefer. The codec + value types are
DI-agnostic and have no dependency on the OpenIddict server side, so a
separate package is feasible if the maintainers prefer to keep the
federation package's surface focused on the OIDC server wire.


RiverSync usage context

RiverSync is a six-app product platform. Its OIDC identity-provider role
is centralized in a single application (Account); the other five
(Portal, Admin, Partners, Pipeline, Field) participate strictly
as OIDC relying parties. The following four anchors explain why the
WASM-safe codec primitive is load-bearing on our side — and why we are
asking for a consumable upstream primitive rather than quietly shipping
a RiverSync-only Common.Claims.

Anchor 1 — Field WASM/offline already consumes this codec

The framing for the upstream maintainer: Field already does this; the
upstream ask is to make the consumable primitive available from upstream
packages, not to invent a new codec.

Anchor 2 — Epic 029 is generalizing AccessScope to Common.Domain

  • File:
    agile/epics/029-common-oss-foundation-layer/029-03-generalized-scope-claim-primitives/spec.md.
    Status IMPLEMENTED (line 5). 029-03-U1 AC1 verbatim: "Given two
    compatible scopes, When Intersect(a, b) is called, Then the result
    is the narrower scope (OrgWide ∩ Region = Region; Region ∩ Region
    same Id = Region)." AC2: "Given incompatible scopes, When
    Intersect is called, Then it returns null (no widening)." AC3:
    "Given the type, When serialized/deserialized or constructed from
    Guid ids, Then values round-trip deterministically."
  • The ask to upstream is therefore NOT "we invented this and we want
    you to copy us" — it is "we need this primitive in the shared
    package because we are already generalizing our own internal copy to
    Common.Domain and our offline decoder cannot reach into Account
    for the truth table."

Anchor 3 — Epic 018's ViewAsContext is the analog precedent for the consumable shape

  • File:
    Common/src/Web/ViewAsContext.cs.
    ViewAsContext is a POCO constructed per render with a
    ClaimsPrincipal; it is NOT a DI service. Read-only properties
    (IsViewAs, ActorSubject, SessionId, WriteIsViewAs) are
    consumed by every product app's MainLayout — same pattern we want
    upstream to commit to for the codec decoder.
  • Never-throw invariant (lines 53-59): the decoder is on the
    per-request MainLayout render path; a throw would log out every
    user on a corrupt token. All decoding is wrapped in try/catch
    with malformed input degrading to "no session" — the exact same
    shape we want for the scope-codec decoder the upstream ask proposes.
  • Both-claims-required / all-or-nothing (lines 143-158): either
    alone is a malformed token and yields IsViewAs = false. Same
    never-throw contract the upstream codec decoder would want.
  • Wire-name stabilityact is the wire name (RFC 8693 §2.2.1)
    even though OpenIddict's internal alias is actor; the T08
    wire-remap handler renames it. The upstream codec should similarly
    commit to a stable wire name for the packed perm value (and a
    stable per-triple shape for decoded output) so consumers do not
    need to know the producer's internal naming.

"RiverSync's epic 018 (Cross-Tenant Access) shipped a
POCO-constructed, never-throw decoder for the federated act+vas
pair at Common/src/Web/ViewAsContext.cs, with read-only
IsViewAs / ActorSubject / SessionId / WriteIsViewAs
properties consumed by every product app's MainLayout. The same
shape — POCO, never-throw, both-claims-required, wire-name
stable — is the analog we want for an upstream
PermScope/AccessScope value type and packed perm codec."

Anchor 4 — ARCH:VI (Account is the sole writer)

Per architecture rule
.claude/rules/architecture/federation-auth.md
(extract ARCH:VI):

  • Account is the sole platform OIDC/OAuth2 identity provider — the
    only issuer of the federated token every app trusts.
  • The other five product apps (Portal, Admin, Partners, Pipeline, Field)
    participate strictly as OIDC relying parties; none runs its own
    authorization server or credential store.
  • Common provides consumable shapes (claims codec, RP wiring,
    ViewAsContext); it does NOT host a token issuer.

The upstream ask in 031-04 follows the same pattern: the codec
decoder is the consumable shape; the issuer-side state (claim
construction, scope resolution, policy engine) is upstream's
responsibility, not RiverSync's.
This framing keeps the contribution
small, well-scoped, and aligned with 018's proven precedent.

No timeline promises (explicit)

To be unambiguous about what this issue is not asking:

  • No timeline promise. This issue does not commit RiverSync to any
    particular release date for adopting any upstream release. Internal
    sequencing is governed separately by our own epic planning and may
    change without notice to this issue. Concretely: no timeline or
    commitment by RiverSync is tied to the resolution of this issue.
  • No adoption commitment. RiverSync has not committed to taking a
    hard Wangkanai.Federation (or shared claims package) dependency
    even after this issue is resolved. The thin Common strategy in
    029/030 is OSS-agnostic and is being executed on Wangkanai.Domain
    • Wangkanai.System today; any deeper Federation adoption is
      contingent on legal/architectural review on our side (including the
      P0 license clarity from 031-01).
  • No code contribution attached. This is a clarification /
    consumable-primitive ask, not a roadmap sketch. The P2
    AccessScope / PermScope / AppKey entitlement shapes (031-05)
    are a separate filing, sequenced behind this P1 codec ask.
  • No licensing-philosophy request. We are not asking upstream to
    change their licensing philosophy — only to ship WASM-safe pure
    primitives (no DI / no I/O) plus a deterministic packed perm
    codec with the contracts in §Request above. License clarity is
    already tracked separately in the P0 issue (031-01, Filed).

Why now

This is a near-term need, not a long-horizon roadmap item — for three
converging reasons that make the WASM/offline consumer pattern visible
upstream as a real, shipped property:

  1. Field's WASM/offline decode is real and shipping. Epic 017
    shipped the Blazor WebAssembly client with an IndexedDB-backed
    offline authentication state provider (017-04-U1) and a
    FieldAccountClaimsPrincipalFactory (017-03-U2) that both depend
    on a shared PermClaimCodec. The codec is the load-bearing
    primitive that makes offline reads reconstruct authorization state
    with no network round-trip. Once an upstream equivalent exists,
    Field can take a hard dependency on it instead of a RiverSync-only
    Common.Claims reference.
  2. Epic 029-03 has already generalized the analog type internally.
    The "narrowing" semantics of AccessScope (closed-set, no
    widening, null on incompatible) are now part of RiverSync's
    Common.Domain (029-03 status IMPLEMENTED). Asking upstream for
    the codec primitive that this internal value type will eventually
    project onto aligns the upstream contract with what we are
    already executing internally.
  3. Epic 018's ViewAsContext proves the consumable shape works.
    Six RiverSync apps consume ViewAsContext per-render without DI,
    without throws, with stable wire names — exactly the shape the
    upstream codec decoder needs. The precedent is shipped, tested,
    and exercised in production.

Without an upstream consumable codec primitive, every consumer of
federation-issued tokens who needs offline / WASM / deterministic
behavior either re-implements the codec (fracturing the wire
contract) or reaches for a non-federation dependency (defeating the
purpose of having an IdP library). This issue is asking for a
WASM-safe, DI-free, deterministic, versioned, hard-error-on-
unknown
codec that an offline consumer can depend on, with the
test surface documented in this filing.


Worked fixture (byte-exact, asserted by RiverSync's test suite)

For the maintainer's quick verification, the codec round-trips this
worked fixture byte-exact (asserted by
Common/test/Unit/Claims/PermClaimCodecTests.cs:60-82):

Map:  { (admin, manage): OrgWide, (portal, edit): Region("rid"), (portal, view): OrgWide }
Wire: "v1:admin:manage|portal:edit@region:rid|view"

The v1: prefix is the version marker (line 195-196 in
PermClaimCodec.cs); the | pipe separates both app boundaries and
perms within a group; the @region:rid is the inline scope suffix.
Org-wide emits no suffix (admin:manage and portal:view both
omit the @<kind>:<id> form). Decode("v1:admin:manage|portal:edit@region:rid|view")
returns the original PermScopeMap byte-equivalent.

The overage persona (200 entries) and the past-threshold always-v1z:
behaviour are tested at lines 144-191 and 197-215 of the same file;
the forward-compat v1:-decodes-after-v2:-ships behaviour is
tested at lines 269-287.


References


Drafted by RiverSync for upstream filing against wangkanai/federation
(or a shared claims package per the maintainers' preference);
not yet filed. Once filed, the upstream URL will replace this header note
and the tracker row in docs/oss-contributions.md
will be updated to Filed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions