Aller au contenu principal

Implementation Plan: Governance Policy Matrices (Merge Policy + Tool Grants)

Structure and implementation detail for ./spec.md. The spec states what; this document states how.

Feature ID: policy-matrices Status: Implemented Last updated: 2026-08-22


1. Architecture Summary

Both matrices are split into a pure half and an I/O half, and the pure half is split again between the contracts package and the agent package. That three-way split is the central design decision:

@ever-works/contracts types + constants + shape guards + pattern matchers
src/policy/ (zero-dependency; every writer can import it)


@ever-works/agent resolution + decision (pure, side-effect free)
src/policy/*.ts resolveMergePolicyChain / evaluateAgentMerge
│ resolveToolGrantChain / decideToolGrant

@ever-works/agent services + repositories (I/O: load the four rows)
src/policy/*.service.ts MergePolicyService / ToolGrantService


injection tokens MERGE_POLICY_ENFORCER / TOOL_GRANT_ENFORCER
│ consumed via @Optional() @Inject(...)

apps/api HTTP surface for operators
src/merge-policy/ GET /api/merge-policy/resolve
src/tool-grants/ GET/PUT/DELETE /api/tool-grants

Why the shape guards live in contracts, not agent. Validating a five-field JSON object should not drag the entity graph in. The writers are plural — the Work/Agent update paths in the agent package, the organization update path in the API, and any future importer — and they only ever touch the wire shape. Putting sanitizeMergePolicyOverride / sanitizeToolGrantOverride next to their types keeps contracts the one import site for anyone who is not already inside the agent package. The agent package re-exports them so @ever-works/agent/policy remains a single import site inside it.

Why the pure half is separate from the services. Resolution and decision are side-effect free, so the precedence rules can be unit-tested without a database and the same functions can run in the API, the worker, or a future edge caller.

Why injection tokens rather than direct imports. Both enforcers are token + contract only, in leaf files with type-only imports — the same circular-dependency dodge used by the other agent injection tokens (see docs/architecture/agent-injection-tokens.md). GitFacadeService consumes MERGE_POLICY_ENFORCER; AgentRunService, AgentToolService and SkillsService consume TOOL_GRANT_ENFORCER. All use @Optional() @Inject(...), so leaving the token unbound restores pre-feature behaviour exactly (spec E7).

2. Tech Choices

ConcernChoiceWhy
Override storage (merge)simple-json columns on tenants/organizations/works/agentsAdditive, no join, no new table for a five-field partial object.
Override storage (grants)Dedicated tool_grants tableGrants are user-scoped and independently listable/deletable; they need their own row identity.
Validation postureDrop-if-unrecognised, on the way in to resolutionsimple-json round-trips whatever was written and API/import payloads carry junk. Coercing could loosen a rule.
Precedence enforcementResolver sorts layers itselfA caller that passed layers in the wrong order would invert the whole matrix; sorting removes that class of bug.
Merge foldField-by-field over MERGE_POLICY_FIELDSLets a Work change one knob and inherit the rest (spec FR-8).
Grant foldIntersection for allow, union for denyEncodes narrow-only + additive-deny (spec FR-17, FR-18).
Pattern matchingHand-rolled * / prefix* / exact, case-insensitiveA glob library would accept syntax the spec does not define; case-insensitivity is a stated security property.
Credential ref matcherFactory returning a fresh /g RegExpA shared /g instance carries lastIndex and silently skips matches on the second call (spec FR-25).
Enforcer binding@Optional() @Inject(TOKEN)Fails safe (pre-feature behaviour), not closed, on a DI mistake.

3. Data Model

Columns / entities

  • mergePolicy — a nullable simple-json column carrying a MergePolicyOverride on each of tenants, organizations, works, agents.
  • tool_grants — a user-scoped table holding one ToolGrantOverride per (scope, scope-id) pair.

Migrations

Forward-only, per Constitution Principle V:

  • 1784000000000-AddMergePolicyColumns
  • 1784780000000-CreateToolGrants

Contracts

packages/contracts/src/policy/:

FileContents
merge-policy.types.tsMergeMethod, MergePolicy, MergePolicyOverride, MergePolicyScope/Source, chain + decision types, MERGE_METHODS, MERGE_POLICY_SCOPE_PRECEDENCE, PLATFORM_DEFAULT_MERGE_POLICY, MERGE_POLICY_FIELDS
merge-policy.sanitize.tssanitizeMergePolicyOverride
tool-grant.types.tsToolGrantScope/Source, ToolGrantOverride, ToolGrantMatrix, chain + decision types, TOOL_GRANT_SCOPE_PRECEDENCE, PLATFORM_DEFAULT_TOOL_GRANT, matchesToolPattern, matchesAnyToolPattern, toolPatternCovers, TOOL_NAME_PATTERN, TOOL_GRANT_PATTERN, credentialRefPattern, CREDENTIAL_KEY_PATTERN, isCredentialKey
tool-grant.sanitize.tssanitizeToolGrantOverride
agent-capabilities.types.tsAGENT_INIT_SCRIPT_MAX_BYTES and the capability shapes

4. API Surface

Method + pathPurpose
GET /api/merge-policy/resolveEffective merge policy + source + chain for a scope combination.
GET /api/tool-grants/resolveEffective matrix + source + chain.
GET /api/tool-grants/checkDecide a single tool name against the effective matrix.
GET /api/tool-grantsList stored grant rows for the caller.
PUT /api/tool-grantsUpsert one grant row.
DELETE /api/tool-grants/:idDelete one grant row.
GET /api/agents/:id/capabilitiesAgent-scoped capability projection that reflects the resolved matrix.

All inherit the global AuthSessionGuard. Grant rows are user-scoped, so reads and writes filter on the caller's userId.

5. Plugin Surface

None. This is core governance with no external provider to swap — Constitution Principle I does not apply (see spec §9).

6. Web / CLI Surface

The HTTP surface exists and is consumable today. Dedicated settings screens for editing the matrices are tracked separately (spec §6, Out of Scope).

7. Background Jobs

None. Resolution and decision are synchronous, pure and sub-millisecond, so Constitution Principle IV does not apply.

8. Security & Permissions

  • Deny beats allow, always, so a broad ancestor allow cannot defeat a targeted descendant deny.
  • No upward widening: a child may only keep allow patterns an ancestor already covers, so delegating a scope to a less-trusted operator cannot escalate it.
  • Case-insensitive matching — a grant that misses on case is a security bug, not a nicety.
  • Fail closed when a policy cannot be evaluated (unknown target branch while branches are protected).
  • Fail safe when the feature itself is unwired — an unbound enforcer restores pre-feature behaviour rather than taking the product down.
  • Credential references ({{cred.<key>}}) resolve server-side immediately before the outbound call; the resolved value is never logged, persisted or echoed back to the model (Constitution Principle VII).

9. Observability

Every resolution returns a chain that attributes each contributed field or pattern to a scope, plus the patterns that were rejected by the narrowing rule. Refusals carry a stable machine-readable code alongside a human reason that names the offending value, so logs and UI can group by code while still telling the operator what to change.

10. Phased Rollout

  1. Contracts: types, constants, shape guards, pattern matchers.
  2. Agent package: pure resolution + decision functions.
  3. Agent package: services + repositories (the I/O half).
  4. Injection tokens + optional binding at the consumption sites.
  5. API: operator endpoints.
  6. Tests pinning every constant, branch and refusal code.

11. Risks & Mitigations

RiskMitigation
A caller passes layers in the wrong order and inverts the matrix.The resolver sorts by the documented precedence itself; callers cannot invert it.
Junk in a simple-json column silently loosens a rule.Validate on the way in; drop unrecognised values rather than coercing them.
An empty list is mistaken for "unset" and silently inherits.Empty-but-declared is explicitly distinguished from entirely-invalid; both paths are unit-tested.
A DI mistake leaves an enforcer unbound and disables governance.Documented, deliberate: unbound = pre-feature behaviour. The alternative (fail closed) would be an outage.
A shared /g regex skips matches on its second use.The credential matcher is a factory; every caller gets its own instance.
A future tool name in a new case convention slips past a grant.Matching is case-insensitive by construction.
A silent addition to a constant list changes behaviour unnoticed.Unit tests pin every list's members, count and order.
PLATFORM_DEFAULT_MERGE_POLICY's nested arrays are not frozen, so a stray .push() widens a safety control process-wide.Latent only — the resolver clones before folding. Pinned by test; deep-freezing is tracked as spec OQ-5.

12. Constitution Reconciliation

See ./spec.md §9. Principles I, II, IV and VIII are non-applicable and explained there; III, V, VI, VII, IX and X are satisfied.

13. References