@geonosis/policy
v3.0.0
Published
A threshold is a value plus the authority that set it, the subject it reaches and the reason it exists. The precedence lattice, the rule → config → constant read with its fallback journalled once per boot, the learning loop, the generated policy prose, an
Maintainers
Readme
@geonosis/policy
A threshold is not a number. It is a number, plus the authority that set it, the subject it reaches and the reason it exists — and that difference is what lets the person who was just blocked learn something instead of filing a ticket.
That sentence is true of two tiers, and the earlier version of this README claimed only the first, which is why one consumer read the whole package as not applicable and was right to.
| tier | the question it answers | what it costs |
|---|---|---|
| runtime — definePolicy / readPolicy | what does this tenant, in this region, for this order get? | a store behind three ports, a boot, and an await at every read |
| compile-time — declareThreshold | on whose authority is this number 3000? | one call, no ports, no store, no async, no override |
Most thresholds are the second question. LIVENESS_TIMEOUT_MS = 3000 decides behaviour in code and
will never be tenant-settable; what it lacks is not a lattice, it is an authority. Putting it behind
the runtime tier would have cost a port and an await to answer a question nobody was asking, and
that is precisely why it stayed a magic number.
The runtime half is the kernel two repos arrived at independently: a precedence lattice with a full trace, a read that resolves authored rule → config row → the caller's own constant and RECORDS the third case, the learning loop that reads those recordings back, and a permission lattice with the runtime lifted out of it.
import { definePolicy, readPolicy } from '@geonosis/policy'
const policy = definePolicy({
tiers: [
'regulatory',
'contract',
{ name: 'tenant', overridable: true },
{ name: 'command', overrides: true },
{ name: 'learned', advisory: true },
],
kinds: [{ kind: 'quoteExpiryDays', schema: z.number().positive() }],
scopeKeys: ['region', 'customerGroup', 'companyId', 'orderId'], // VAGUEST first
})
const { value, usedFallback } = await readPolicy(policy, 'quoteExpiryDays', { companyId }, {
fallback: DEFAULT_QUOTE_EXPIRY_DAYS,
rule,
config,
journal,
op: 'create-quote',
})The kernel is inert until the data arrives. Not one tier, kind, scope key or rule is built in: the
two consumers disagreed on every one of them — one's strongest authority is regulatory, the other's
is physics, and they share not a single scope key — so all four are declared. A policy with no
rules decides nothing, every read falls through to the caller's constant, and each of those is
recorded exactly once per boot: the honest unit of evidence for "nobody has set this knob" is a
deployment, not a request, and a constraint read once per cart would otherwise write a row per cart
and drown the one signal anybody is looking for.
What is in it
| Export | What it does |
|---|---|
| definePolicy({ tiers, kinds, scopeKeys, rules }) | The lattice as data. Refuses two escape hatches, a duplicate tier or kind, and an advisory tier declared stronger than an authority. |
| policy.resolve(kind, query, rules) | The decision plus the whole trace: winner, suppressed, overrode, conflicts, explain. |
| policy.unusableRules() · unsupportedScopes() · unsetKinds() | The three honesty reports. A rule this build cannot honour is NAMED, never silently dropped; a kind a reader asks for that no rule sets is named too, because a reader falling back forever looks identical to one that is working. |
| readPolicy(policy, kind, scope, opts) | rule → config → constant, through injected rule / config / journal ports. |
| policyOrDefault(policy, input, apply) | A resolved value that cannot be APPLIED is the same kind of gap: recorded, and the default takes over. A default that also fails still throws — that is our bug, not a misconfiguration. |
| generatePolicyMarkdown · describePolicy | The policy as committed prose, and as the bounded instructions an agent gets at connect. Every count and the precedence sentence are derived. |
| observeJournal / reflect / curate / promote / demote / playbookRules | The learning loop. A promoted learning enters at whatever tier the policy calls advisory, so it can only ever fill silence. |
| journalRowForEvent | The other three signals' missing input: real lifecycle events as countable rows. |
| rulesToSeed(seed, existing) | Seeding idempotent by id AND by coverage. |
| runPolicyAdapterConformance(harness) | The suite every storage adapter passes, answering with a report rather than an exit code. |
| declareThreshold({ value, authority, reason, subject?, revisit? }) | The compile-time tier. Frozen, so there is no runtime override; refused at the declaration — which is module load — when the authority, the reason or the value is missing, or the revisit is not an ISO date. |
| expiredThresholds(list, now) · describeThreshold · isThreshold | A revisit that has passed is REPORTED, never thrown: a build that started failing on a date is a build that gets its date deleted. |
| @geonosis/policy/permissions | definePolicies / hasPermission / resolvePermissions / checkPermissions / createPolicyCatalog / expandPolicies. |
| @geonosis/policy/medusa · /drizzle | Two stores behind the same ports. Neither imports the store it adapts. |
Migrating a Medusa storefront's rules engine, in five lines
lib/rules.ts→definePolicy({ tiers: POLICY_TIERS, kinds: …, scopeKeys: … }); the twelvePolicyConstraintmembers become twelve{ kind, schema }, andKNOWN_SCOPE_KEYS+scopeSpecificityboth disappear intoscopeKeys(vaguest first — the weight is the position).lib/read-policy.ts→readPolicy(policy, kind, query, { fallback, …adapter }); the SLA tier dereference becomesisReference+dereferenceon theapprovalSlaHourskind.lib/config-store-binding.ts→createMedusaPolicyAdapter({ container, moduleKey, bindings })withconfigRow(namespace, key); droppositive()from every binding — the kind's schema now refuses junk once, for the rule layer too.lib/learning.ts→ the same functions;playbookRules(policy, playbook)takes the policy, andjournalRowForEvent(name, data, { subjectKeys, actorKeys })takes the fifteen event names' keys.modules/rules-engine/service.tskeepsMedusaServiceand its three models, and loses the mapping:listActiveRules()is the adapter'srule.rules(), andresolve()ispolicy.resolve.
proofs/022-W7-parity/ lists every one of its 65 kernel cases against this package, the ten that
stay consumer-side, and the seven behaviours that deliberately differ.
A threshold without an authority is a gate
{ "packs": [{ "pack": "thresholds", "options": { "sourceRoots": ["packages", "apps"] } }] }@geonosis/verify-arch's thresholds pack, two checks:
- T1.1 — a module-scope
constwhose name is threshold-shaped (_MS,_LIMIT,MAX_,_TTL,_SECONDS, …) and whose value is a bare number. It is a number that decides behaviour and can say only "me, an hour ago" when asked why. - T1.2 — one such name declared with two different values in two files. That is the half no
per-file linter can reach:
grepfinds both, and only something holding the whole graph can say they disagree.
The name shapes are a default rather than an empty list, for the reason rails defaults its
threat classes and not its caps.auth: _MS and MAX_ are the units and bounds a threshold is
spelled in, and none of them is one repo's word for one repo's thing. A repo adds its own with
names, excuses one with allow, and names its own wrapper with helper.
A bound declared inside a function is left alone — it is a step in an algorithm, not a repo-wide decision — and the anchor that does that is column zero.
a Workers + D1 app, corrected
The earlier version of this section read: "Its thresholds are lint-enforced constants … They are
not [policy]. grep -E '_LIMIT|_THRESHOLD|MAX_|_CAP' across all 34 [constants.ts files] returns
nothing." Both halves need correcting.
The grep was over the wrong denominator. It searched the 34 constants.ts files, and the
fixture is not in one: LIVENESS_TIMEOUT_MS = 3000 lives in
apps/api/src/lib/health-status.ts, module-private, three lines above its only use. Run over the
whole of packages/ and apps/ on 2026-09-02, the pack finds seven, and the four cache TTLs
that section listed as not policy are among them:
packages/db/src/constants.ts:6 PLACEMENT_CACHE_SECONDS = 300
packages/db/src/constants.ts:7 PLACEMENT_HOT_READ_SECONDS = 30
packages/auth/src/constants.ts:10 PRINCIPAL_HOT_READ_SECONDS = 30
apps/web/features/document/lib/history-stack.ts:1 MAX_PAST = 100
apps/edge/src/constants.ts:5 API_KEY_CACHE_SECONDS = 300
apps/edge/src/constants.ts:9 UNKNOWN_KEY_CACHE_SECONDS = 60
apps/api/src/lib/health-status.ts:8 LIVENESS_TIMEOUT_MS = 3000And "not policy" was the wrong conclusion from the right observation. The observation — none of
them carries an authority, a subject or a reason — is exactly what separates a policy rule from a
constant, and the answer to it is the compile-time tier, not the runtime one. §13.3 is a law about
WHERE a constant lives; declareThreshold is about who said so. Seven declarations, no store, no
port, no await.
What that repo could still adopt on the runtime side, without changing a line of behaviour, is the
shape of the call — its constant passed as fallback, so the gap becomes countable — and that is
worth precisely as much as its journal is fed, which today is not at all.
D-027 status: one consumer with pull. C3 of docs/challenge-2026-08-30.md ranked this behind
@geonosis/integrations for that reason and it was right to; this package ships inside 1.0.0 with
a Medusa storefront's kernel as its only live consumer and musa's lattice as a second fixture, not a second
consumer. It is DONE when a Medusa storefront depends on the published version and has deleted its lib/.
What it deliberately does NOT do
- No global registry. Upstream's
definePolicieswrites into threeglobalThisobjects keyed by name with no duplicate check, and rewrites the caller's objects in place.createPolicyCatalogis local and refuses a name already taken. - No store. Rules, config rows and journal entries all arrive through ports. That is what lets every check in the conformance suite run without a booted server.
- No opinion about your tiers. Including the one that looks universal: whether an unmarked rule
may be overridden is
overridableByDefault, because the two consumers answer it oppositely.
Consumable from CommonJS
A Medusa backend is module: Node16 CommonJS by upstream requirement, not by preference. This
package ships both builds and a require condition carrying its own .d.cts, so a static import
type-checks and require() works:
import { definePolicy } from '@geonosis/policy' // ESM, module: Node16 or ESNext
const { definePolicy } = require('@geonosis/policy') // CommonJSAn ESM-only package is a wall for such a backend, and no amount of parity gets over it: a Medusa storefront
measured @geonosis/testbed at 15/15 identical and still could not adopt it, because a static
import is TS1479 and await import() cannot serve the synchronous describe() registration its 107
suites are built on.
