feat(contracts): initial implementation (v1.0.0)
Introduces code.nochebuena.dev/einherjar/contracts — the zero-dependency foundation of the Einherjar framework. Defines the interfaces and minimal types consumed by every starter. Zero external dependencies. Zero Einherjar dependencies. Nothing is above it in the dependency graph. lifecycle: - Component — OnInit, OnStart, OnStop three-phase lifecycle hooks observability: - Level (LevelCritical=0, LevelDegraded); zero value is the safe default - Checkable — HealthCheck, Name, Priority - Identifiable — ModulePath, ModuleVersion; implemented by all starters to surface module identity and version in the startup banner logging: - Logger — Debug, Info, Warn, Error, With, WithContext errs: - CodedError — ErrorCode() string; satisfied by core/xerrors.Err - ContextualError — ErrorContext() map[string]any; satisfied by core/xerrors.Err security: - Identity value type — UID, TenantID, DisplayName, Email; NewIdentity, WithTenant - Permission (int64), MaxPermission=62, PermissionMask — Has, Grant - PermissionProvider — ResolveMask(ctx, uid, resource) (PermissionMask, error) - SecurityBag value type — immutable request-scoped security context; carries Identity and arbitrary typed attributes (hardware IDs, grant codes, etc.); With copies the attribute map on every call to preserve receiver-invariant behaviour - NewSecurityBag, Identity, WithIdentity, Get, With - SetBagInContext / BagFromContext — full bag context storage - SetInContext / FromContext — backed by SecurityBag; all four cross-function combinations (SetInContext+BagFromContext, SetBagInContext+FromContext) are valid One file per type; CT-6 enforced by compliance test AST walk.
This commit is contained in:
102
docs/adr/ADR-001-errs-sub-package.md
Normal file
102
docs/adr/ADR-001-errs-sub-package.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# ADR-001: errs Sub-package — Formalising the Error Enrichment Contract
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Module:** `code.nochebuena.dev/einherjar/contracts`
|
||||
- **Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
In micro-lib, `logz` and `xerrors` are decoupled through duck typing. `logz` defines
|
||||
two private interfaces internally:
|
||||
|
||||
```go
|
||||
// inside logz — never exported
|
||||
type errorWithCode interface {
|
||||
ErrorCode() string
|
||||
}
|
||||
type errorWithContext interface {
|
||||
ErrorContext() map[string]any
|
||||
}
|
||||
```
|
||||
|
||||
`xerrors.Err` happens to satisfy both. `logz` detects this at runtime via
|
||||
`errors.As`. The contract exists — it just lives nowhere. Any type that implements
|
||||
`ErrorCode() string` and `ErrorContext() map[string]any` gets the enrichment
|
||||
behaviour, whether its author knew about `logz` or not.
|
||||
|
||||
This works in a flat library collection where both packages are authored by the same
|
||||
team. It breaks down in a framework context for two reasons:
|
||||
|
||||
1. **No canonical definition.** A developer implementing a custom error type that
|
||||
wants logger enrichment has no interface to implement against — they must read
|
||||
`logz`'s source code to discover the implicit contract.
|
||||
|
||||
2. **Impossible to mock or test cleanly.** A test that wants to verify error
|
||||
enrichment behaviour cannot assert against a defined interface. It must rely on
|
||||
the duck-type resolution happening implicitly.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce `contracts/errs` as a sub-package with two exported interfaces:
|
||||
|
||||
```go
|
||||
// errs/coded_error.go
|
||||
type CodedError interface {
|
||||
ErrorCode() string
|
||||
}
|
||||
|
||||
// errs/contextual_error.go
|
||||
type ContextualError interface {
|
||||
ErrorContext() map[string]any
|
||||
}
|
||||
```
|
||||
|
||||
`core/logz` imports `contracts/errs` and checks against these interfaces explicitly
|
||||
instead of defining private duck-typed equivalents. `core/xerrors` declares compile-time
|
||||
satisfaction:
|
||||
|
||||
```go
|
||||
var _ errs.CodedError = (*Err)(nil)
|
||||
var _ errs.ContextualError = (*Err)(nil)
|
||||
```
|
||||
|
||||
The clean separation is preserved — `logz` still does not import `xerrors`, and
|
||||
`xerrors` still does not import `logz`. Both now depend on `contracts/errs`, which
|
||||
has zero dependencies of its own.
|
||||
|
||||
## Why Two Interfaces, Not One
|
||||
|
||||
The interfaces are intentionally separate. ISP (Interface Segregation Principle)
|
||||
requires that no consumer is forced to implement methods it does not use.
|
||||
|
||||
An error may carry a machine-readable code without structured context fields. An
|
||||
error may carry structured context fields without a machine-readable code. Forcing
|
||||
both into a single `RichError` interface would require implementors to provide both
|
||||
— a constraint that is not justified by the actual use cases.
|
||||
|
||||
`logz` checks each independently via `errors.As`, which is exactly how the
|
||||
duck-typed version already worked. The two-interface shape matches the actual
|
||||
consumption pattern.
|
||||
|
||||
## Why in `contracts`, Not in `core`
|
||||
|
||||
`contracts` is the only module guaranteed to have zero Einherjar dependencies. If
|
||||
`CodedError` and `ContextualError` lived in `core`, any module that wanted to
|
||||
implement them would be forced to import `core` — pulling the launcher, logger, and
|
||||
validator into its dependency graph. That violates the Separated Interface pattern
|
||||
that `contracts` exists to enforce.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:** Custom error types have a clear interface to implement against.
|
||||
`go doc code.nochebuena.dev/einherjar/contracts/errs` is the single authoritative
|
||||
answer to "what does my error need to provide for logger enrichment to work?"
|
||||
Compile-time verification replaces implicit duck-type discovery.
|
||||
|
||||
**Harder:** `errs` is a sub-package that did not exist in micro-lib. Developers
|
||||
migrating from micro-lib must adopt these interfaces explicitly rather than relying
|
||||
on the duck-type bridge. This is a one-time migration cost with no ongoing burden.
|
||||
|
||||
**New obligations:** `CodedError.ErrorCode()` and `ContextualError.ErrorContext()`
|
||||
signatures are permanent from this release. They may not be changed without a major
|
||||
version bump on `contracts`.
|
||||
78
docs/adr/ADR-002-file-per-type-naming.md
Normal file
78
docs/adr/ADR-002-file-per-type-naming.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# ADR-002: File-per-Type Naming Convention
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Module:** `code.nochebuena.dev/einherjar/contracts`
|
||||
- **Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Framework-level ADR-004 establishes that `contracts` sub-packages contain one file
|
||||
per interface or type (CT-6). It does not specify how those files are named. Without
|
||||
an explicit convention, names tend to drift: `types.go`, `interfaces.go`, `models.go`,
|
||||
or `common.go` — filenames that say nothing about what is inside.
|
||||
|
||||
The naming problem compounds in `security`, which has five declarations across five
|
||||
files. A developer looking at the directory listing needs to know immediately which
|
||||
file to open.
|
||||
|
||||
## Decision
|
||||
|
||||
Every source file in `contracts` is named after the single type or interface it
|
||||
declares, converted to lowercase and snake_cased for multi-word names.
|
||||
|
||||
| Declaration | File |
|
||||
|---|---|
|
||||
| `Component` | `component.go` |
|
||||
| `Checkable` | `checkable.go` |
|
||||
| `Level` | `level.go` |
|
||||
| `Logger` | `logger.go` |
|
||||
| `CodedError` | `coded_error.go` |
|
||||
| `ContextualError` | `contextual_error.go` |
|
||||
| `Identity` | `identity.go` |
|
||||
| `Permission` | `permission.go` |
|
||||
| `PermissionMask` | `permission_mask.go` |
|
||||
| `PermissionProvider` | `permission_provider.go` |
|
||||
|
||||
`doc.go` files are the sole exception — they carry the package-level doc comment and
|
||||
export nothing.
|
||||
|
||||
Constants, package-level variables, and functions that are semantically part of a
|
||||
type's API (constructors, context helpers, builder methods) coexist in the same file
|
||||
as the type they serve. They are not counted as separate declarations for CT-6
|
||||
purposes — CT-6 governs type declarations, not every exported symbol.
|
||||
|
||||
Examples:
|
||||
- `level.go` declares `Level` and also defines `LevelCritical` and `LevelDegraded`
|
||||
- `permission.go` declares `Permission` and also defines `MaxPermission`
|
||||
- `identity.go` declares `Identity` and also defines `NewIdentity`, `WithTenant`,
|
||||
`SetInContext`, and `FromContext`
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Single `types.go` per sub-package.** Rejected — a file named `types.go` provides
|
||||
no information to a developer scanning a directory. They must open it to know what
|
||||
is inside.
|
||||
|
||||
**Interface files named `interface.go` or `contract.go`.** Rejected — same reason.
|
||||
The filename should answer "what contract does this file define?" not "what kind of
|
||||
file is this?"
|
||||
|
||||
**Separate files for each function and constant.** Rejected — excessive fragmentation.
|
||||
A constructor and the type it constructs are a single conceptual unit. Splitting them
|
||||
forces a developer to open two files to understand one thing.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:** `find . -name "permission_mask.go"` returns exactly the file that defines
|
||||
`PermissionMask`. A directory listing of `security/` reads as a vocabulary list for
|
||||
that sub-package's domain. Code review diffs are unambiguous — a change to
|
||||
`permission_provider.go` is a change to the `PermissionProvider` contract.
|
||||
|
||||
**Harder:** Multi-word type names require a deliberate naming decision at the file
|
||||
level. The snake_case rule eliminates ambiguity (`permission_mask.go` is the only
|
||||
valid name for `PermissionMask`) but must be applied consistently across all modules.
|
||||
|
||||
**New obligations:** Every Einherjar module inherits this convention. When a new type
|
||||
is added to any module, its file is named after the type. This is not optional — the
|
||||
compliance test enforces the one-type-per-file structural invariant, and the naming
|
||||
convention is the human-readable complement to that mechanical check.
|
||||
11
docs/adr/index.md
Normal file
11
docs/adr/index.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# ADR Index — contracts
|
||||
|
||||
Module-level architecture decisions for `code.nochebuena.dev/einherjar/contracts`.
|
||||
|
||||
For framework-wide decisions see the
|
||||
[Einherjar docs repository](https://code.nochebuena.dev/einherjar/docs).
|
||||
|
||||
| ADR | Title | Status |
|
||||
|---|---|---|
|
||||
| [ADR-001](ADR-001-errs-sub-package.md) | errs Sub-package — Formalising the Error Enrichment Contract | Accepted |
|
||||
| [ADR-002](ADR-002-file-per-type-naming.md) | File-per-Type Naming Convention | Accepted |
|
||||
Reference in New Issue
Block a user