Publishing Authority Data from a Proprietary System
It contains no vendor, company, product or real identifier, and can be handed to a customer as-is.
The claim-by-claim file:line verification lives separately in
publishing-contract-verification.md — internal, do not distribute.
Audience: platform / IAM engineers exposing an in-house system.
You do not need to know our graph model. Describe your system in your own terms — accounts, groups, grants, resources, deployments — and we compile it. Where our compiler has a limit that changes what you must send, this document says so plainly.
Status legend: [Today] marks behaviour that is implemented and in use. Everything described
below is [Today] unless the text says otherwise in the same sentence; where a field is not accepted
yet, this document says so and tells you not to send it.
0. The shape of the ask
For each system you expose, you publish a full-state snapshot of a declared scope: every object in scope, every reference between them, and a statement of what you could and could not read. We recompute reach from scratch on each snapshot. There are no deltas to maintain.
Three rules govern everything else:
- A local reference must be satisfied locally. When a relationship names an object by the id you
assigned in that payload, that object must be present in the same payload. An endpoint naming an
id you did not declare is dropped.
[Today]the drop is counted and returned to you on the publish record, split by which end was missing — so a snapshot that quietly lost most of its relationships no longer reads as a clean run. See §4. - To attach to an object you did not send, use a typed cross-payload reference.
[Today]You name the object by(system name, object id)instead of by a local id, and we resolve it against what we already hold. This is how a multi-system estate is published as several payloads instead of one — see §2.1. The alternative remains available and is sometimes simpler: re-send the object under its exact identity, which merges rather than duplicates. - Identifiers are exact strings. We key objects on
(system name, object id)byte-for-byte with no normalisation. Change the case, trim a realm suffix, or switch an id format between publishes, and you have created a different object and orphaned every reference to the old one.
1. What you give us
Each object needs a stable id, a display name, and a status (active / disabled /
deleted / expired). Those three are required everywhere and are not repeated below.
1.1 Directory — people and accounts
| Field | Unlocks | |
|---|---|---|
| Required | (id, name, status) | The account exists and can hold grants. |
| Optional | email / upn | Recognising the same person across systems. Without it, an account here and an account in another system stay two unrelated things, and "who is behind this" stops at your system's boundary. |
| Optional | owner / created-by reference | Named accountability. With no owner reference and no owner-ish property, the account is reported as ownership unknown once it has reach. |
| Optional | source creation time | Real age in the timeline. Without it, age is measured from the day we first saw the object, not from when it was created. |
| Optional | group memberships (incl. nested) | See §3.1 — the publisher we build with you flattens these. Without them, a member of grp-payroll-admins holds none of that group's grants as far as we are concerned. |
Distinguish people from non-human / service accounts and from teams. A team is an owner, not a principal — sending teams as accounts inflates your identity count.
1.2 Authentication realm — how one thing becomes another
Publish each trust or assumption as a directed reference: this principal can authenticate as / assume that principal.
| Field | Unlocks | |
|---|---|---|
| Required | from-id, to-id, relation kind | The hop is walkable. |
| Optional | credential reference | Naming which credential mediates the hop in the resulting chain. Without it, the hop shows as unattributed. |
| Optional | since / until | Time-bounding the hop. |
Note the budget in §3.3: two boundary crossings per chain.
1.3 Entitlement system — grants
This is where snapshots most often fail to produce anything. Three parts:
(a) The holder link — which account or bundle holds which grant bundle. (b) The bundle → grant link — which concrete grants the bundle confers. (c) The grant itself:
| Field | Unlocks / cost of omission | |
|---|---|---|
| Required | action list (non-empty) | A grant with no action list is discarded entirely. It contributes no reach and produces no finding. [Today] the number of distinct grants discarded this way is returned on your publish record (§4), so the omission is visible without asking us. |
| Required | target reference — the resource(s) the grant applies to | A grant with no target produces nothing at all. Not a weaker path; no path. |
| Alternative to a target | explicit scope pattern (*-style) plus an impact class of write or escalation | Wildcard authority becomes visible as a scope endpoint instead of vanishing. [Today, per-tenant setting, off by default] — with the setting off, a wildcard grant with no concrete target is silently absent. |
| Optional | impact class per grant (read / execute / write / escalation) | Capability findings — "this can now do something it could not before". Without it, a grant is reach with no severity. |
| Optional | conditions on the grant | We render them verbatim beside the grant, labelled condition configured, not evaluated. We never simulate them. |
| Optional | since / until | Grant lifetime. |
Composition: we follow exactly one level — holder → bundle → grant. If a bundle includes other bundles, flatten it to leaf grants before publishing; the nesting is not expanded.
Delegation: "A may act on behalf of B" is stored and displayed but never followed. If delegation genuinely confers reach, publish it as an authentication relation (§1.2) or as effective grants on the delegate.
1.4 Deployment / application registry — what runs
| Field | Unlocks | |
|---|---|---|
| Required | (id, name, status) + the identity it runs as | The workload's reach is the reach of the identity it runs as. Without the run-as link, a deployment is inert inventory. |
| Optional | calls / invokes references | Chains across your own components. These do not consume the crossing budget. |
| Optional | environment label | Detecting environment crossings (BU-04-nonprod reaching BU-04-prod). Absence is never defaulted to production — an unlabelled object contributes nothing to the environment trail. |
| Optional | endpoint URI | Matching a caller to the thing it calls when the two live in different systems. Matched on lowercased host+path, default port and query string stripped; ambiguous matches (one URL serving many targets) are dropped rather than guessed. |
| Optional | owner / team | Named accountability — see §5.3. |
1.5 Credential store
| Field | Unlocks | |
|---|---|---|
| Required | (id, name, status) | The credential exists. |
| Optional | which identity it authenticates as | The credential becomes a named step in the chain rather than a dangling secret. |
| Optional | expiry / until | Expiry-driven findings. |
Never send secret material. Send references.
1.6 Data store / resource inventory — the things reached
| Field | Unlocks | |
|---|---|---|
| Required | (id, name, status) | Can be the endpoint of a path. |
| Optional | sensitivity — one of public / internal / confidential / restricted | Impact ranking. Omitted ⇒ unknown, and the resource ranks below every classified one. |
| Optional | business domain | Grouping reach by business area. Omitted ⇒ unknown. |
| Optional | environment label | As §1.4. |
1.7 Activity records — what actually ran (optional, high value)
| Field | Unlocks | |
|---|---|---|
| Required (if sending any) | subject reference, source table, record id, timestamp, action, target, outcome | Distinguishing could reach from did reach. |
| Optional | confidence tier | Maps to the strength label on every claim: Confirmed / Correlated / Configured. Absent ⇒ claims stay at Configured — what the configuration permits — never Confirmed. |
| Optional | target resource key on each record | Per-destination attribution. Without it, activity counts are summed workload-wide; if one workload has two or more distinct destinations, both inherit the same count and the over-attribution is flagged, not hidden. |
| Optional | proof notes, event count | Verbatim "what this does and does not prove" text; de-duplicated counts. |
1.8 Scope and completeness — required with every publish
State what the snapshot covers (full / incremental / targeted, which systems, which object
kinds) and, per data source, whether you read it successfully: available, partial,
unavailable_no_access, unavailable_not_enabled, unavailable_not_applicable.
This is a safety interlock, not paperwork. A source marked partial or unavailable_no_access is
excluded from deletion inference — objects you could not read are not treated as removed.
Silence here is the dangerous case: an unreported read failure looks exactly like a mass deletion.
Not accepted today: declarative correlation keys, and per-source-record lineage. Both are named on our internal payload type, but neither is on the schema that validates an arriving snapshot, so both are discarded on receipt — not stored, not readable back, not present on anything you can query. Do not build extraction for them. Their disposition — added to the contract, or dropped from the type — is tracked on our side, and this page will change when it does.
2. How things reference each other — the join-key contract
Within a snapshot. Every reference names objects by the id you assigned in that same payload. Endpoints not present are dropped (§0, rule 1).
2.1 Cross-payload references [Today]
One logical estate is usually several systems, and they rarely export together. A typed
cross-payload reference names an object by its (system name, object id) rather than by a local id,
so a later payload can attach to something an earlier one established — or to something a first-party
connector already holds.
This is a declared foreign key, not an inference. You are asserting the exact identity of an object you did not send, and we look it up. It is a different mechanism from the cross-system key matching in the table above, which infers that two differently-named things are the same; nothing here is guessed.
Properties worth knowing before you design around it:
- Unresolved is an error by default, not a silent drop. If a reference names something we do not hold, the publish is rejected — no objects, no relationships, no reach, no activity records are written. You may opt into report-only per payload, in which case the publish proceeds with a thinner graph and the counts come back on the record instead. This is the opposite of rule 1's local-endpoint behaviour, and deliberately so: a cross-payload reference is an explicit assertion that the target exists, so failing it quietly would defeat the point.
- Acceptance of the upload is not resolution. References are resolved while we process the snapshot, not while we receive it, so a successful upload response does not mean they resolved. The verdict — and, when it goes against you, a sample of the exact keys that failed, echoed back as you sent them — lands on the publish record, which you can read yourself (§4).
- A rejected publish id is spent. Correct the keys and resubmit under a new publish id; re-sending the same id is treated as a duplicate and enqueues nothing.
- The target must be live. A reference to an object that exists but has been removed does not resolve, and is reported separately from one that was never there — the two mean different bugs (a key-format mistake versus a timing race with the payload that owns the object).
- Order matters. References resolve against what is already stored, so publish in dependency order: the payload that owns an object before the payloads that point at it.
- A reference is not ownership. Pointing at an object grants you nothing over it — you cannot change its properties, and its absence from your next snapshot never causes its removal.
- Repeated references are free. Many edges naming the same object collapse to a single lookup, so pointing ten thousand relationships at one group costs one resolution, not ten thousand.
- Referencing something you also sent is not an error. If the same payload declares the object as a node and names it by reference, both land on the same object. It is counted back to you as redundant so you can drop the duplicate work, and nothing breaks if you do not.
Across snapshots and over time. An object's identity is (system name, object id), hashed
exactly as given. The authoritative side is the publisher of the object. Two publishers
describing the same object must emit byte-identical (system name, object id) — that, and only
that, is what merges them.
Across systems. Where two systems name the same thing differently, we match on a small set of normalised keys. Normalisation here is deliberate, and different from the identity rule above:
| Key | Normalisation | Ambiguity handling |
|---|---|---|
| Email / UPN | trimmed, lowercased; no other rewriting | many-to-many matches dropped entirely |
| Cloud-style resource identifier | lowercased, one trailing / stripped; account and region preserved | all matches kept |
| OAuth client / application id | lowercased | first match wins |
| Endpoint URL | lowercased host+path, default port stripped, query and fragment dropped | many-to-many matches dropped entirely |
| Resource path id | normalised | all matches kept |
Format is load-bearing
SVC-PAYROLL@CORP.EXAMPLE.INTERNALandsvc-payroll@corp.example.internalare two different objects at the identity layer, and the same person at the email-correlation layer. Both behaviours are correct and neither substitutes for the other.- Stripping the realm (
svc-payroll@corp.example.internal→svc-payroll) "to tidy up" mints a new object, orphans every grant pointing at the old id, and — because unresolved references are dropped — does it without an error. - Whatever id format you choose, freeze it. An id format change between publishes reads to us as the old population was deleted and a new one created.
3. What we do with it
3.1 Flattening — and exactly where it happens
The ingest engine flattens nothing. It walks the relationships it is given. Flattening is done by the publisher, which for a first integration we build with you — that is what "assisted" means in §0's engagement model, and it is why the mapping is our deliverable rather than your homework.
- Group nesting → effective grants. The reach engine does not walk membership. The publisher expands your memberships — including nesting — into direct account-to-grant attachments, stamping the origin on each so the inheritance stays auditable. The corollary: a grant attached to a group you did not send, or a membership you did not send, contributes nothing.
- Bundle composition. One level only (§1.3). The publisher flattens nested bundles to leaf grants.
- Delegation is not flattened, resolved, or followed by anything (§1.3). If it confers real reach, model it as an authentication relation.
If you write your own publisher rather than using the one we build with you, all three become yours — and none of them fails loudly. That is the single biggest reason a first integration is assisted.
3.2 What we compute
From each starting workload or account we walk: held grants → the actions they confer → the resources they apply to, plus run-as bindings, internal call/invoke chains, credential-mediated hops, and cross-system authentication hops. Output: who can reach what, with which actions, through which named steps — plus, if you sent activity records, whether it was used.
3.3 The crossing budget
Internal forwarding — calls, invocations, credential use, run-as — is free. Only boundary crossings are budgeted: cross-system authentication hops and cross-system correlation matches. The budget is two crossings per chain. A chain that must cross three system boundaries is recorded up to the second crossing and no further.
We record one route per (start, destination), not every distinct route. If two routes reach the same resource, one is shown.
4. What you get back
Every accepted publish yields a durable record, held against your publish id.
The same credential you publish with reads your publish records back — one record by publish id, and a list of your recent publishes. Both are read-only, and both are scoped to your own tenant: the record is resolved against the tenant your credential is bound to, never against a tenant named in the request, so a credential cannot reach another customer's publishes. That same credential is refused everywhere else in our API — it cannot read objects, reach or findings.
So the diagnostics below are yours to poll and to gate your own pipeline on: publish, poll the publish id until the record reaches a terminal state, read the counters, decide whether to continue. Ordered multi-payload publishing runs on exactly that loop, without us in it.
The record contains, [Today]:
- objects created / updated / affected; change events recorded
- reach recomputed: paths and chains created, updated, removed
- completeness of keying — objects with versus without a canonical resource key, same for activity records; a direct read on how much of your snapshot can be attributed per-destination
- deletion exclusions — which sources were skipped for deletion inference and why
(
partial/unavailable_no_access), present only when something was excluded - circuit-breaker trips — if a single publish would remove more than 50% of objects, or of existing reach, the removal is blocked and the record says how many were blocked and at what ratio. A blocked publish is a signal to check your extraction, not a failure to retry blindly.
- invariant checks — five run per publish, each with a verdict and a sample of offenders: dangling references from computed reach to missing objects; activity-count attribution consistency, including explicit flagging of counts shared ambiguously across distinct destinations; declared-scope versus delivered-graph agreement; aggregate and pagination consistency
4.1 Publish diagnostics — what your snapshot lost, and where [Today]
Every completed publish also carries a diagnostics block, and it is written unconditionally. That matters more than it sounds: a zero means we measured this and it was clean, never we did not look. The block is what tells you a snapshot that reported a healthy object count had quietly lost most of its relationships.
- Relationships dropped because an endpoint was never declared — separated by which end was missing, plus a total. This is rule 1 of §0, now counted rather than silent.
- Activity records attached by a relationship we do not consume — counted separately, because they look attached and contribute nothing. A non-zero here means the record was delivered but is not being read as evidence.
- Grants discarded for a missing action list — the §1.3 discard, as a count of distinct grants. De-duplicated by grant, so the number tracks how many grants are malformed rather than how many accounts happen to hold them.
- Cross-payload references (§2.1) — how many relationship endpoints declared one; how many distinct objects that was; how many resolved; and the unresolved split into no such object and object exists but has been removed. Plus: relationships dropped for an unresolved reference, references that were redundant (you also sent that object as a node in the same payload), which policy was in force, and a capped sample of the keys that failed — echoed back exactly as you sent them, never enriched with anything you did not supply.
- Reach actually computed — routes computed, how many crossed a system boundary, how many span two or more systems end to end, and the deepest authentication chain reached.
- Which publishing identity we bound the payload to, when the publish arrived on a credential we could bind. Present only when it was measured; an absent value means not measured, which is not the same as a failed binding.
For a single-system snapshot the healthy shape — account → bundle → grant → resource — crosses no boundary and spans no second system, so the boundary and cross-system counters read zero on a perfectly good publish. Zero there is only meaningful against a snapshot you expected to reach across systems. Route count is the counter to read first.
A worked example, from a seven-payload on-premises estate published into one of our demonstration tenants: 36 relationship endpoints declared a cross-payload reference. Each payload de-duplicates its own references before looking them up, and across the seven that came to 32 lookups, all 32 of which resolved — no unresolved references, no relationships dropped, no grants discarded, and 38 routes spanning more than one system, at a maximum authentication chain depth of 1. Note the shape of that first pair: 36 endpoints declared against 32 looked up, because within a payload more than one relationship can point at the same object and only one lookup is spent on it. Reading it as "36 resolved" would overstate it.
Absence is reported, never scored as success. A source you could not read is an exclusion on the record, not a clean run. Zero findings on a snapshot that excluded half your entitlement system is zero findings about the half we saw.
5. What we cannot answer, and why
5.1 Configured, not simulated. We report the authority your configuration grants. We do not evaluate conditional logic on grants — conditions are shown verbatim and explicitly labelled configured-not-evaluated. "This grant is conditioned on a context we did not model" is a limit we print, not one we paper over.
5.2 Reach is not use. Without activity records, every claim is Configured — what the configuration permits. Confirmed requires records you supply; Correlated means the link was inferred from timing rather than observed directly. We never promote a claim above the evidence you sent, and the weaker of claim strength and runtime strength always wins.
5.3 Individual accountability for a shared service account is your assertion. If the only owner reference on an account points at a team or group, we report ownership as ambiguous — we can name the team, not the person. A named individual can be recorded through the ownership-assignment interface; that record carries the asserting principal, the timestamp, optional team and notes, and can be revoked with a reason. We store, attribute and date the assertion. We do not verify it, and it does not suppress the underlying finding about the source system's state.
5.4 Structurally invisible unless the publisher flattens it. Membership nesting and nested bundle composition are flattened by the publisher, and only from what you send it; delegation is flattened by nothing. Anything left un-flattened is not partially visible — it is absent, and silently so.
5.5 Beyond two boundary crossings. See §3.3. Longer chains are truncated, not reported as complete.
5.6 Route enumeration. One route per (start, destination). Do not read our output as an exhaustive route list.
5.7 Removal is inferred from absence within your declared scope. It is only as trustworthy as §1.8. Excluded sources are never inferred as deletions; large removals are blocked by the circuit breaker rather than applied.
6. How it arrives
The cadence is yours. Two modes, both full-state:
-
You publish. POST a snapshot to the ingest endpoint on whatever schedule you set. Responses:
202accepted,200if the publish id was already seen (safe to retry — publishes are de-duplicated on your id),409if the snapshot violates an admission rule,429withRetry-After: 30when the queue is saturated,413if the snapshot exceeds the size ceiling below.202means accepted for processing, not processed. Whether your cross-payload references resolved, and everything §4 reports, is decided while we process the snapshot — not while we receive it. Treat the response code as a handoff receipt and the publish record, which you poll under your own credential, as the verdict.Size: the binding limit is ~14 MB, and chunking does not raise itThe HTTP layer will accept a much larger body, and there is a three-call chunked flow (init → chunk… → commit). Neither raises the real ceiling. Both transports assemble the whole snapshot and hand it to a single durable job, and that job is capped at roughly 14 MB. A snapshot above it is refused with a
413carrying the actual and maximum sizes — on the publish call for a single-shot submission, on the commit call for a chunked one. This is not a transient failure and must not be retried unchanged; split the snapshot.So chunking is a way to move a large body over HTTP, not a way to publish a larger snapshot. Size your snapshots against the ~14 MB job ceiling, and split by scope — publish a system, or a subsystem, per snapshot — rather than by transport.
-
We read on your schedule. You configure a per-scope interval; a scheduler dispatches each scope when its next run is due, subject to a per-scope concurrency cap and a post-failure cooldown you set. Missed windows are skipped, never backfilled — replaying a full-state snapshot has no meaning.
We do not claim continuous coverage. Freshness equals your publish interval. Between publishes, our answer is the state of your last accepted snapshot, timestamped. Findings are recomputed after each accepted publish, not on a separate clock.