Manifest contract
The bridge’s output is a laravel-iam.manifest.v2 document — the declarative contract the IAM server
validates and registers. This page specifies the document the bridge produces and how ManifestGenerator
builds it.
The document
{
"schema": "laravel-iam.manifest.v2",
"app": {
"key": "billing",
"name": "Billing",
"type": "laravel",
"risk_level": "low"
},
"permissions": [
{ "key": "orders.refund", "risk": "high" },
{ "key": "manage_users", "risk": "low" }
],
"roles": [
{ "key": "admin", "permissions": ["orders.refund", "manage_users"] },
{ "key": "viewer", "permissions": [] }
]
}
| Field | Type | Source |
|---|---|---|
schema |
string | constant "laravel-iam.manifest.v2" |
app.key |
string | --app option (default legacy; falls back to legacy if blank) |
app.name |
string | --name option (default = app.key) |
app.type |
string | constant "laravel" |
app.risk_level |
string | constant "low" |
permissions[] |
{ key, risk } |
one per surviving slugged Spatie permission |
roles[] |
{ key, permissions[] } |
one per Spatie role; permissions[] are surviving keys |
How the generator maps the inventory
// ManifestGenerator::generate($scan, $app)
Permissions: slug, dedup, risk
$key = $this->mapper->toKey($name);
if (isset($seen[$key])) {
continue; // semantic duplicate → keep the first
}
$seen[$key] = true;
$permissions[] = ['key' => $key, 'risk' => $this->mapper->inferRisk($key)];
Each permission name is slugged; the first occurrence of a key wins and later collisions are dropped (a
semantic duplicate to review). risk
comes from inferRisk().
Roles reference only surviving keys
foreach ($role['permissions'] as $permName) {
$mapped = $this->mapper->toKey($permName);
if (isset($seen[$mapped]) && !in_array($mapped, $permKeys, true)) {
$permKeys[] = $mapped; // only keys that exist as real permissions
}
}
$roles[] = ['key' => $this->mapper->toKey($role['name']), 'permissions' => $permKeys];
A role only references a permission key that survived as a real permission. A role pointing at a blank or
deduplicated permission never produces a dangling reference — the manifest stays internally consistent.
Invariants the generator guarantees
- No dangling role references. Every key in a role’s
permissions[]exists in the top-level
permissions[]. - No duplicate permission keys. The
seenset guarantees uniqueness. - Every key is valid. All keys pass through
PermissionMapper::toKey, so they satisfy
^[a-z][a-z0-9_.-]*$. app.keyis never empty. A blank--appfalls back tolegacy.
Validation is the server’s job
The generated document is a proposal. Structural correctness is enforced by the server’s
iam:manifest:validate against the laravel-iam.manifest.v2 schema, and a human approves the semantics
(risk levels, role composition) before iam:app:register applies it. The bridge never registers a manifest
itself.
ADR — generate a consistent proposal, validate on the server
Problem. If the generator produced manifests with dangling references or duplicate keys, validation would
fail downstream and the migration would stall on mechanical errors.
Decision. Guarantee internal consistency at generation time (dedup, surviving-key references, valid
slugs), but leave authority with the server validator and a human reviewer. The generator proposes; the
server disposes.
Consequences. The proposal is always structurally registrable, so review focuses on semantics, not
plumbing. The bridge intentionally does not embed the full schema — that lives in
laravel-iam-server/laravel-iam-contracts, the single source of truth — so the two cannot drift.
app.type(laravel) andapp.risk_level(low) are constants here — set the real values on the
server side if they differ.- Deduplication is silent in the JSON; the inventory
report.mdis where collisions are visible. - Direct user permissions from the scan are not turned into roles — they need an explicit decision.
Next
- Permission slugging — how keys are produced.
- Manifest schema reference — the field-by-field contract.
- Manifest generation guide — running the command.