@stigg/agent-sdk-js 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1147 -2
  2. package/dist/bindings/client/core/registry.d.ts +12 -0
  3. package/dist/bindings/client/core/registry.js +22 -0
  4. package/dist/bindings/client/core/types.d.ts +117 -0
  5. package/dist/bindings/client/core/types.js +13 -0
  6. package/dist/bindings/client/core/wrap-client.d.ts +6 -0
  7. package/dist/bindings/client/core/wrap-client.js +258 -0
  8. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  9. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
  10. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  11. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  12. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
  13. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
  14. package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
  15. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  16. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
  17. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +91 -0
  18. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  19. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  20. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
  21. package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
  22. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
  23. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
  24. package/dist/bindings/proxy/headers.d.ts +6 -0
  25. package/dist/bindings/proxy/headers.js +12 -0
  26. package/dist/bindings/proxy/index.d.ts +21 -0
  27. package/dist/bindings/proxy/index.js +18 -0
  28. package/dist/bindings/proxy/types.d.ts +8 -0
  29. package/dist/bindings/proxy/types.js +2 -0
  30. package/dist/bindings/shared/estimate.d.ts +27 -0
  31. package/dist/bindings/shared/estimate.js +38 -0
  32. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  33. package/dist/bindings/shared/meter-stream.js +151 -0
  34. package/dist/bindings/shared/token-usage.d.ts +148 -0
  35. package/dist/bindings/shared/token-usage.js +414 -0
  36. package/dist/bindings/shared/types.d.ts +79 -0
  37. package/dist/bindings/shared/types.js +2 -0
  38. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  39. package/dist/bindings/shared/weighted-metrics.js +44 -0
  40. package/dist/config.d.ts +136 -0
  41. package/dist/config.js +97 -0
  42. package/dist/core/context.d.ts +151 -0
  43. package/dist/core/context.js +154 -0
  44. package/dist/core/dimensions.d.ts +22 -0
  45. package/dist/core/dimensions.js +47 -0
  46. package/dist/core/hooks.d.ts +21 -0
  47. package/dist/core/hooks.js +47 -0
  48. package/dist/core/session.d.ts +161 -0
  49. package/dist/core/session.js +403 -0
  50. package/dist/decision-text.d.ts +15 -0
  51. package/dist/decision-text.js +54 -0
  52. package/dist/engine/composite-engine.d.ts +72 -0
  53. package/dist/engine/composite-engine.js +135 -0
  54. package/dist/engine/create-engine.d.ts +16 -0
  55. package/dist/engine/create-engine.js +93 -0
  56. package/dist/engine/decision.d.ts +37 -0
  57. package/dist/engine/decision.js +128 -0
  58. package/dist/engine/engine.d.ts +166 -0
  59. package/dist/engine/engine.js +11 -0
  60. package/dist/engine/node-sdk-engine.d.ts +125 -0
  61. package/dist/engine/node-sdk-engine.js +262 -0
  62. package/dist/engine/report.d.ts +106 -0
  63. package/dist/engine/report.js +185 -0
  64. package/dist/engine/rest-governance-engine.d.ts +47 -0
  65. package/dist/engine/rest-governance-engine.js +220 -0
  66. package/dist/errors.d.ts +78 -0
  67. package/dist/errors.js +151 -0
  68. package/dist/headers.d.ts +27 -0
  69. package/dist/headers.js +63 -0
  70. package/dist/index.d.ts +48 -0
  71. package/dist/index.js +85 -0
  72. package/dist/internal.d.ts +33 -0
  73. package/dist/internal.js +81 -0
  74. package/dist/logger.d.ts +15 -0
  75. package/dist/logger.js +20 -0
  76. package/dist/sdk-identity.d.ts +36 -0
  77. package/dist/sdk-identity.js +43 -0
  78. package/dist/stigg-agent-sdk.d.ts +242 -0
  79. package/dist/stigg-agent-sdk.js +594 -0
  80. package/dist/types.d.ts +531 -0
  81. package/dist/types.js +97 -0
  82. package/package.json +80 -3
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CompositeEngine = exports.isAttributed = void 0;
4
+ /**
5
+ * Routes each of the four operations to whichever delegate serves it, and re-routes on its own
6
+ * when one stops answering.
7
+ *
8
+ * The delegates are not equals. REST can serve everything; the Node SDK is an optimization - it
9
+ * has the cache, so ingest prefers it - and the governance surface exists only on REST. So the
10
+ * demotion rule is one-directional: an optimization that proves unreachable is dropped in favour
11
+ * of the complete implementation, never the other way around. Demoting REST for a transport blip
12
+ * would silently trade chains for no chains, which is a worse answer, not a slower one.
13
+ */
14
+ const errors_1 = require("../errors");
15
+ const errors_2 = require("../errors");
16
+ /**
17
+ * Whether this check is about an entity at all.
18
+ *
19
+ * `dimensions` is the only attribution a check carries - the REST call sends nothing else that
20
+ * could resolve one - so an empty map means the governance tree is never consulted and the answer
21
+ * is the customer's pool either way. Registration is deliberately NOT the signal: an agent
22
+ * provisioned last week is governed by a run that registers nothing today.
23
+ */
24
+ const isAttributed = (params) => Object.keys(params.dimensions).length > 0;
25
+ exports.isAttributed = isAttributed;
26
+ class CompositeEngine {
27
+ capabilities;
28
+ delegates;
29
+ resolution;
30
+ fallback;
31
+ logger;
32
+ /**
33
+ * Which `operation::client` pairs have failed in a way another transport could survive. Keyed
34
+ * by the pair rather than by the engine: one client can serve two operations over two different
35
+ * services, and a GraphQL outage must not disable an entitlement read that never went there.
36
+ */
37
+ demoted = new Set();
38
+ /** What each delegate is, so a result names the client that actually answered it. */
39
+ sourceOf = new Map();
40
+ constructor(deps) {
41
+ this.delegates = deps.delegates;
42
+ this.resolution = deps.resolution;
43
+ this.fallback = deps.fallback;
44
+ this.logger = deps.logger;
45
+ const { ops } = deps.resolution;
46
+ const label = (engine, source) => {
47
+ if (engine)
48
+ this.sourceOf.set(engine, source);
49
+ };
50
+ label(deps.delegates.upsertEntity, ops.upsertEntity);
51
+ label(deps.delegates.check, ops.check);
52
+ label(deps.delegates.unattributedCheck, ops.unattributedCheck ?? ops.check);
53
+ label(deps.delegates.report, ops.report);
54
+ label(deps.delegates.archiveEntity, ops.archiveEntity);
55
+ // Last, so the complete implementation wins where it also serves as a delegate.
56
+ label(deps.fallback, 'rest');
57
+ this.capabilities = {
58
+ strictCheck: deps.delegates.check.capabilities.strictCheck,
59
+ chains: deps.delegates.check.capabilities.chains,
60
+ // What the delegate says it can do, not merely that it has the method: every engine
61
+ // implements `upsertEntity`, and one that cannot provision must not be routed to.
62
+ provisioning: deps.delegates.upsertEntity?.capabilities.provisioning ?? false,
63
+ };
64
+ }
65
+ // async, so a missing provisioner surfaces as a rejection like every other failure here -
66
+ // a promise-returning method that throws synchronously splits every caller's error handling.
67
+ async upsertEntity(customerId, entity, placement) {
68
+ await this.run('upsertEntity', this.provisioner(), (engine) => engine.upsertEntity(customerId, entity, placement));
69
+ }
70
+ async archiveEntity(customerId, entityId) {
71
+ await this.run('archiveEntity', this.provisioner(), (engine) => engine.archiveEntity(customerId, entityId));
72
+ }
73
+ async check(customerId, params) {
74
+ const delegate = (0, exports.isAttributed)(params)
75
+ ? this.delegates.check
76
+ : (this.delegates.unattributedCheck ?? this.delegates.check);
77
+ return this.run('check', delegate, (engine) => engine.check(customerId, params));
78
+ }
79
+ async report(customerId, params) {
80
+ return this.run('report', this.delegates.report, (engine) => engine.report(customerId, params));
81
+ }
82
+ /**
83
+ * Warms the delegate that would serve this check - not every delegate that holds a cache.
84
+ *
85
+ * Routed by the same rule `check` uses, because warming anything else is a round trip spent on
86
+ * a cache this context will never read: an attributed check under governance goes to REST,
87
+ * which caches nothing, so the honest answer there is to do nothing. Deliberately not run
88
+ * through `run()`: a warm-up that cannot reach its transport is not worth demoting an operation
89
+ * over, and the first real check will discover the same outage and apply the error policy.
90
+ */
91
+ async preload(customerId, dimensions) {
92
+ const delegate = (0, exports.isAttributed)({ dimensions })
93
+ ? this.delegates.check
94
+ : (this.delegates.unattributedCheck ?? this.delegates.check);
95
+ await delegate.preload?.(customerId, dimensions);
96
+ }
97
+ async close() {
98
+ const unique = new Set([...Object.values(this.delegates), this.fallback].filter(Boolean));
99
+ await Promise.all([...unique].map((engine) => engine.close?.()));
100
+ }
101
+ /**
102
+ * Runs an operation on its delegate, and once - and only once per delegate - retries on the
103
+ * complete implementation if the failure says the transport is unreachable rather than the
104
+ * request wrong.
105
+ */
106
+ async run(operation, preferred, call) {
107
+ const key = `${operation}::${this.sourceOf.get(preferred) ?? 'unknown'}`;
108
+ const engine = this.demoted.has(key) && this.fallback ? this.fallback : preferred;
109
+ try {
110
+ return await this.stamp(engine, await call(engine));
111
+ }
112
+ catch (err) {
113
+ const canRetry = this.fallback && engine !== this.fallback && (0, errors_1.isTransportFailure)(err);
114
+ if (!canRetry)
115
+ throw err;
116
+ this.demoted.add(key);
117
+ this.logger?.warn(`[stigg] ${operation} could not reach its usual transport and was re-routed for the rest of this process: ` +
118
+ `${err instanceof Error ? err.message : String(err)}`);
119
+ return this.stamp(this.fallback, await call(this.fallback));
120
+ }
121
+ }
122
+ /** Results carry the client that actually answered, which is not always the one planned. */
123
+ stamp(engine, result) {
124
+ if (!result || typeof result !== 'object' || !('source' in result))
125
+ return result;
126
+ return { ...result, source: this.sourceOf.get(engine) ?? 'rest' };
127
+ }
128
+ provisioner() {
129
+ const delegate = this.delegates.upsertEntity;
130
+ if (!delegate?.capabilities.provisioning)
131
+ throw new errors_2.GovernanceNotEnabledError();
132
+ return delegate;
133
+ }
134
+ }
135
+ exports.CompositeEngine = CompositeEngine;
@@ -0,0 +1,16 @@
1
+ import type { EngineConfig } from '../config';
2
+ import type { CacheOptions, NodeSdkClient } from './node-sdk-engine';
3
+ import type { EngineResolution, GovernanceEngine } from './engine';
4
+ export type CreatedEngine = {
5
+ engine: GovernanceEngine;
6
+ resolution: EngineResolution;
7
+ };
8
+ export type EngineClientOptions = {
9
+ apiKey: string;
10
+ baseUrl?: string;
11
+ timeoutMs: number;
12
+ /** Both ignored by the REST client, which caches nothing and builds its own transport. */
13
+ cache?: CacheOptions;
14
+ client?: NodeSdkClient;
15
+ };
16
+ export declare function createGovernanceEngine(config: EngineConfig): Promise<CreatedEngine>;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createGovernanceEngine = createGovernanceEngine;
4
+ /**
5
+ * Picks the engine and reports what it picked.
6
+ *
7
+ * The rule is SDK-first: the Node server SDK serves everything it can (it has the cache), and the
8
+ * REST client completes what only governance has. Governance itself is optional - not every
9
+ * account has it - so an account without it still gets a working customer-level engine rather
10
+ * than an error.
11
+ */
12
+ const composite_engine_1 = require("./composite-engine");
13
+ const node_sdk_engine_1 = require("./node-sdk-engine");
14
+ const rest_governance_engine_1 = require("./rest-governance-engine");
15
+ const CUSTOMER_LEVEL_OPS = {
16
+ upsertEntity: 'unavailable',
17
+ check: 'node-sdk',
18
+ report: 'node-sdk',
19
+ archiveEntity: 'unavailable',
20
+ };
21
+ const GOVERNANCE_OPS = {
22
+ upsertEntity: 'rest',
23
+ check: 'rest',
24
+ // A check with no dimensions resolves no entity, so it is the customer's pool either way -
25
+ // and the cached client can answer that without a round trip.
26
+ unattributedCheck: 'node-sdk',
27
+ report: 'node-sdk',
28
+ archiveEntity: 'rest',
29
+ };
30
+ async function createGovernanceEngine(config) {
31
+ const clientOptions = {
32
+ apiKey: config.apiKey,
33
+ baseUrl: config.baseUrl,
34
+ timeoutMs: config.timeoutMs,
35
+ cache: config.cache,
36
+ client: config.client,
37
+ };
38
+ const nodeSdk = new node_sdk_engine_1.NodeSdkEngine(clientOptions);
39
+ const rest = new rest_governance_engine_1.RestGovernanceEngine(clientOptions);
40
+ // Governance is assumed, and asked about once here rather than declared by the caller: the
41
+ // account already knows the answer, so making someone configure it only creates a second
42
+ // source of truth that can disagree with the first.
43
+ //
44
+ // Together with the probe, because they are two independent waits at startup and neither needs
45
+ // the other's answer. Warming is not optional work: until the Node SDK reports itself
46
+ // initialized every cache read counts as a miss and refetches, so skipping this would pay a
47
+ // round trip per check for as long as initialization takes.
48
+ const [probe] = await Promise.all([
49
+ rest.probe(),
50
+ nodeSdk.ready().catch((err) => {
51
+ // Non-fatal by design. An unreachable cache is a slow client, not a broken one - every read
52
+ // falls through to the API - and the first real check is where that surfaces as policy.
53
+ config.logger.warn(`[stigg] the entitlement cache did not finish starting up: ${String(err)}`);
54
+ }),
55
+ ]);
56
+ // REST stays in play either way: it is the complete implementation, so it is what the composite
57
+ // re-routes to if the Node SDK turns out to be unreachable.
58
+ if (probe === 'not-enabled') {
59
+ config.logger.warn('[stigg] governance is not enabled for this account: checks and usage still work at the ' +
60
+ 'customer level, but agents cannot be registered. Contact Stigg to enable it.');
61
+ const resolution = customerLevel('governance not enabled for this account');
62
+ return {
63
+ engine: new composite_engine_1.CompositeEngine({
64
+ delegates: { check: nodeSdk, report: nodeSdk },
65
+ fallback: rest,
66
+ resolution,
67
+ logger: config.logger,
68
+ }),
69
+ resolution,
70
+ };
71
+ }
72
+ const resolution = {
73
+ mode: 'governance',
74
+ ops: { ...GOVERNANCE_OPS },
75
+ reason: 'governance enabled',
76
+ };
77
+ return {
78
+ engine: new composite_engine_1.CompositeEngine({
79
+ // An attributed check goes to REST because only it returns chains; an unattributed one has
80
+ // no chain to lose, and ingest prefers the cached SDK either way.
81
+ delegates: { upsertEntity: rest, check: rest, unattributedCheck: nodeSdk, report: nodeSdk, archiveEntity: rest },
82
+ fallback: rest,
83
+ resolution,
84
+ logger: config.logger,
85
+ }),
86
+ resolution,
87
+ };
88
+ }
89
+ const customerLevel = (reason) => ({
90
+ mode: 'customer-level',
91
+ ops: { ...CUSTOMER_LEVEL_OPS },
92
+ reason,
93
+ });
@@ -0,0 +1,37 @@
1
+ import type { GovernanceDecision, GovernanceRemedy } from '../types';
2
+ import type { EngineCheckResult } from './engine';
3
+ /**
4
+ * Denial reasons map to different products, so they must not be collapsed: an empty wallet is an
5
+ * upsell, an admin-set cap is an escalation, and a missing subscription is neither.
6
+ */
7
+ export declare function remedyFor(reason: string | null | undefined): GovernanceRemedy | undefined;
8
+ export type DecisionContext = {
9
+ customerId: string;
10
+ };
11
+ /**
12
+ * `remaining` is the tightest bound across the pool and every chain node, because the binding
13
+ * constraint is whichever runs out first. It stays undefined when nothing is bounded - the
14
+ * observe-only rollout, where usage is counted but never blocks.
15
+ */
16
+ export declare function toDecision(result: EngineCheckResult, { customerId }: DecisionContext): GovernanceDecision;
17
+ /**
18
+ * Attaches the human-readable halves, so every caller prints the same words. Exported because
19
+ * anything that adjusts a decision after the fact has to refresh them too - a stale summary is
20
+ * worse than none.
21
+ */
22
+ export declare function withDecisionText(decision: Omit<GovernanceDecision, 'summary' | 'message'>): GovernanceDecision;
23
+ /**
24
+ * The decision to return when the engine itself failed and policy resolved the outcome. No
25
+ * `deniedBy`: nobody's budget refused this, so naming an entity would be a lie - the failure
26
+ * detail goes to the `onError` hook instead.
27
+ */
28
+ export declare function policyDecision(hasAccess: boolean): GovernanceDecision;
29
+ /**
30
+ * The decision a run that named no entitlement hands back.
31
+ *
32
+ * Not `policyDecision(true)`: that one says the engine was asked and failed, which is why it carries
33
+ * `reason: 'EngineError'` and `remedy: 'retry'`. Nothing failed here and nothing was asked - there
34
+ * is no budget to ask about - so the only fields true of it are the allow and the source that says
35
+ * why it is one. No `remaining`, because nothing is bounded; no `deniedBy`, because nothing refused.
36
+ */
37
+ export declare function unenforcedDecision(): GovernanceDecision;
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.remedyFor = remedyFor;
4
+ exports.toDecision = toDecision;
5
+ exports.withDecisionText = withDecisionText;
6
+ exports.policyDecision = policyDecision;
7
+ exports.unenforcedDecision = unenforcedDecision;
8
+ /**
9
+ * Turns an engine result into the structured refusal callers branch on.
10
+ *
11
+ * The `chains` array IS the refusal: it names which entity in the hierarchy denied and by how
12
+ * much, so no new primitive is needed on top of it.
13
+ */
14
+ const decision_text_1 = require("../decision-text");
15
+ /**
16
+ * Denial reasons map to different products, so they must not be collapsed: an empty wallet is an
17
+ * upsell, an admin-set cap is an escalation, and a missing subscription is neither.
18
+ */
19
+ function remedyFor(reason) {
20
+ switch (reason) {
21
+ case 'InsufficientCredits':
22
+ return 'top-up';
23
+ case 'BudgetExceeded':
24
+ case 'RequestedUsageExceedingLimit':
25
+ return 'raise-budget';
26
+ case 'NoActiveSubscription':
27
+ case 'NoFeatureEntitlementInSubscription':
28
+ case 'EntitlementNotFound':
29
+ case 'FeatureNotFound':
30
+ return 'subscribe';
31
+ case 'EngineError':
32
+ return 'retry';
33
+ default:
34
+ return undefined;
35
+ }
36
+ }
37
+ /**
38
+ * Credits are fractional, and a subtraction of two of them is not: `5 - 4.4258` lands on
39
+ * 0.5742000000000003, which then reads back to a user as the balance. Rounded to four places,
40
+ * which is finer than any credit currency prices and coarser than the noise.
41
+ */
42
+ const remainingOf = (limit, used) => limit === null || limit === undefined ? undefined : Math.round(Math.max(0, limit - (used ?? 0)) * 1e4) / 1e4;
43
+ const tightest = (values) => {
44
+ const bounded = values.filter((value) => value !== undefined);
45
+ return bounded.length ? Math.min(...bounded) : undefined;
46
+ };
47
+ /**
48
+ * `remaining` is the tightest bound across the pool and every chain node, because the binding
49
+ * constraint is whichever runs out first. It stays undefined when nothing is bounded - the
50
+ * observe-only rollout, where usage is counted but never blocks.
51
+ */
52
+ function toDecision(result, { customerId }) {
53
+ const nodes = (result.chains ?? []).flat();
54
+ const denied = nodes.find((node) => !node.isGranted);
55
+ const reason = result.accessDeniedReason ?? undefined;
56
+ const decision = {
57
+ hasAccess: result.isGranted,
58
+ remaining: tightest([
59
+ remainingOf(result.usageLimit, result.currentUsage),
60
+ ...nodes.map((node) => remainingOf(node.usageLimit, node.currentUsage)),
61
+ ]),
62
+ usageLimit: result.usageLimit,
63
+ currentUsage: result.currentUsage,
64
+ source: result.source === 'unavailable' ? 'policy' : result.source,
65
+ checkedAt: Date.now(),
66
+ };
67
+ if (result.isFallback)
68
+ decision.isFallback = true;
69
+ if (result.locallyDerived)
70
+ decision.locallyDerived = true;
71
+ if (reason) {
72
+ decision.reason = reason;
73
+ decision.remedy = remedyFor(reason);
74
+ }
75
+ if (!result.isGranted) {
76
+ // With no chain (governance off, or a pool-level denial) the customer is what blocked.
77
+ decision.deniedBy = denied?.entityId ?? customerId;
78
+ }
79
+ if (result.isFallback && !result.isGranted) {
80
+ // A denied fallback is not a budget verdict - nobody's limit was consulted, the engine just
81
+ // could not see live state. Saying "raise the budget" here would send the reader somewhere
82
+ // there is nothing to fix.
83
+ decision.reason = 'EngineUnavailable';
84
+ decision.remedy = 'retry';
85
+ delete decision.deniedBy;
86
+ // Nor was it the SDK's arithmetic: whatever the comparison said has just been replaced by
87
+ // "could not see live state", and claiming authorship of a verdict that no longer stands
88
+ // would point a reader at the wrong thing to fix.
89
+ delete decision.locallyDerived;
90
+ }
91
+ return withDecisionText(decision);
92
+ }
93
+ /**
94
+ * Attaches the human-readable halves, so every caller prints the same words. Exported because
95
+ * anything that adjusts a decision after the fact has to refresh them too - a stale summary is
96
+ * worse than none.
97
+ */
98
+ function withDecisionText(decision) {
99
+ const full = decision;
100
+ full.summary = (0, decision_text_1.describeDecision)(decision);
101
+ full.message = (0, decision_text_1.remedyMessage)(full);
102
+ return full;
103
+ }
104
+ /**
105
+ * The decision to return when the engine itself failed and policy resolved the outcome. No
106
+ * `deniedBy`: nobody's budget refused this, so naming an entity would be a lie - the failure
107
+ * detail goes to the `onError` hook instead.
108
+ */
109
+ function policyDecision(hasAccess) {
110
+ return withDecisionText({
111
+ hasAccess,
112
+ reason: 'EngineError',
113
+ remedy: 'retry',
114
+ source: 'policy',
115
+ checkedAt: Date.now(),
116
+ });
117
+ }
118
+ /**
119
+ * The decision a run that named no entitlement hands back.
120
+ *
121
+ * Not `policyDecision(true)`: that one says the engine was asked and failed, which is why it carries
122
+ * `reason: 'EngineError'` and `remedy: 'retry'`. Nothing failed here and nothing was asked - there
123
+ * is no budget to ask about - so the only fields true of it are the allow and the source that says
124
+ * why it is one. No `remaining`, because nothing is bounded; no `deniedBy`, because nothing refused.
125
+ */
126
+ function unenforcedDecision() {
127
+ return withDecisionText({ hasAccess: true, source: 'unenforced', checkedAt: Date.now() });
128
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The engine seam. Deliberately narrow, so engine-specific caching behaviour cannot leak into the
3
+ * public API and v1 can ship on the simplest client and swap later.
4
+ *
5
+ * Caching lives entirely behind this seam. Nothing above it caches a decision, so an engine is
6
+ * free to hold as much state as it can keep correct - which is why `preload` is optional here and
7
+ * `strictCheck` is per engine rather than a property of the SDK.
8
+ */
9
+ import type { Entitlement, IngestMode, Metrics } from '../types';
10
+ /** Which client actually served an operation. Reported back so callers can see what ran. */
11
+ export type EngineOpSource = 'rest' | 'node-sdk' | 'unavailable';
12
+ export type EngineCapabilities = {
13
+ /**
14
+ * False when a check can be served from cached state. Two instances can then each approve a
15
+ * decrement against a balance a sibling already spent, so the SDK promises the weaker semantics
16
+ * rather than documenting hard limits as hard.
17
+ *
18
+ * True for the governance REST client, which holds no cache: every check it serves is a live
19
+ * question to Stigg, and Stigg is fail-closed.
20
+ */
21
+ strictCheck: boolean;
22
+ /** Whether checks carry per-entity governance chains. */
23
+ chains: boolean;
24
+ /** Whether entities and assignments can be written at all. */
25
+ provisioning: boolean;
26
+ };
27
+ export type EngineChainNode = {
28
+ entityId: string;
29
+ isGranted: boolean;
30
+ usageLimit: number | null;
31
+ currentUsage: number;
32
+ scopeEntityIds: string[];
33
+ };
34
+ export type EngineCheckResult = {
35
+ isGranted: boolean;
36
+ accessDeniedReason?: string | null;
37
+ usageLimit?: number | null;
38
+ currentUsage?: number;
39
+ /** One chain per resolved dimension, root to leaf. Absent when governance is not in play. */
40
+ chains?: EngineChainNode[][];
41
+ isFallback?: boolean;
42
+ /**
43
+ * True when the engine reached this verdict itself rather than reading one off a response.
44
+ * A separate axis from `isFallback`, which is about whether the state was live.
45
+ */
46
+ locallyDerived?: boolean;
47
+ source: EngineOpSource;
48
+ };
49
+ export type EngineCheckParams = {
50
+ /**
51
+ * Required, and deliberately so: a run that names no entitlement never reaches here at all -
52
+ * `RunHandle.check` answers it before the engine is consulted. Keeping it required is what makes
53
+ * a future caller that tries to check against nothing a compile error at the seam, rather than an
54
+ * allow invented inside a transport.
55
+ */
56
+ entitlement: Entitlement;
57
+ requestedUsage: number;
58
+ dimensions: Record<string, string>;
59
+ };
60
+ /**
61
+ * How to report a raw-events meter, for features Stigg aggregates from events rather than from
62
+ * values your app computes. The meter is matched by `name`, never by feature id.
63
+ */
64
+ export type EventIngest = {
65
+ /**
66
+ * What the event is called. Every metric rides one event under its own dimension name, so a
67
+ * meter picks its number with `aggregation.field` and this is only what its filter matches on.
68
+ */
69
+ name: string;
70
+ };
71
+ export type EngineReportParams = {
72
+ /** One report per metric under `'sync'`; one event carrying all of them under `'async'`. */
73
+ metrics: Metrics;
74
+ /** Which of Stigg's ingest APIs to use. Chosen by the caller, never by a rejection. */
75
+ mode: IngestMode;
76
+ /** The vendor's own usage object, flattened to scalars. Rides an event; ignored by `'sync'`. */
77
+ raw?: Record<string, string | number | boolean>;
78
+ /**
79
+ * What the run checks against, when it checks anything. A metric named after this credit currency
80
+ * draws the wallet directly; every other metric is a feature, which Stigg converts on its own
81
+ * side. Absent for a metering-only run, where there is no wallet and therefore nothing that is
82
+ * not a feature.
83
+ */
84
+ entitlement?: Entitlement;
85
+ dimensions: Record<string, string>;
86
+ /**
87
+ * Under `'sync'` each metric is keyed `${idempotencyKey}:${featureId}`, so they dedupe
88
+ * independently. Under `'async'` the turn is one event and this key is the whole of it.
89
+ */
90
+ idempotencyKey: string;
91
+ event?: EventIngest;
92
+ };
93
+ export type EngineReportResult = {
94
+ /** What actually landed, by feature id. A metric missing here did not report. */
95
+ reported: Metrics;
96
+ /**
97
+ * Credits this report drew, when Stigg answered with a credit balance. Absent means the
98
+ * features reported are not credit-backed - so the budget the run checks will not drain.
99
+ */
100
+ creditsConsumed?: number;
101
+ currentUsage?: number;
102
+ /** Which API answered. `'async'` is eventually consistent and says nothing about the balance. */
103
+ mode: IngestMode;
104
+ /**
105
+ * Every key had already been ingested - Stigg reported nothing consumed. Undefined under
106
+ * `'async'`, whose empty response cannot tell a first delivery from a replay.
107
+ */
108
+ replayed?: boolean;
109
+ source: EngineOpSource;
110
+ };
111
+ /** Where an entity sits in the tree and what it may spend. Placement rides the assignment. */
112
+ export type EntityPlacement = {
113
+ parentId?: string | null;
114
+ /**
115
+ * Required: an assignment is written over `(entity x entitlement)`, so a placement with nothing to
116
+ * write it against has nowhere to land. `register` refuses that case upstream, which is what keeps
117
+ * this required rather than optional-and-guarded in every transport.
118
+ */
119
+ entitlement: Entitlement;
120
+ /** Set the budget on every provision, overwriting whatever it was. */
121
+ usageLimit?: number | null;
122
+ /**
123
+ * Set the budget only when the assignment does not exist yet. An existing one keeps its limit
124
+ * and cadence, so a raise made in the dashboard survives the next run. Mutually exclusive with
125
+ * `usageLimit` - it costs one extra read, which is why it is not the default.
126
+ */
127
+ usageLimitOnCreate?: number;
128
+ cadence?: string;
129
+ };
130
+ export type EntityInput = {
131
+ id: string;
132
+ entityTypeId: string;
133
+ displayName?: string;
134
+ metadata?: Record<string, string>;
135
+ };
136
+ export interface GovernanceEngine {
137
+ readonly capabilities: EngineCapabilities;
138
+ upsertEntity(customerId: string, entity: EntityInput, placement?: EntityPlacement): Promise<void>;
139
+ check(customerId: string, params: EngineCheckParams): Promise<EngineCheckResult>;
140
+ report(customerId: string, params: EngineReportParams): Promise<EngineReportResult>;
141
+ archiveEntity(customerId: string, entityId: string): Promise<void>;
142
+ /**
143
+ * Loads whatever state this engine caches for a customer, so the checks that follow are local.
144
+ *
145
+ * Optional, and absent is not a failure: an engine that caches nothing has nothing to warm, and
146
+ * the caller treats that as a no-op. `dimensions` is passed so a router can warm the engine that
147
+ * would actually serve this check rather than whichever one happens to hold a cache.
148
+ */
149
+ preload?(customerId: string, dimensions: Record<string, string>): Promise<void>;
150
+ /** Releases whatever the engine holds open (background caches, sockets). */
151
+ close?(): Promise<void>;
152
+ }
153
+ /** Which client serves each operation, and why. Returned by the factory as "the used solution". */
154
+ export type EngineResolution = {
155
+ mode: 'governance' | 'customer-level';
156
+ ops: {
157
+ upsertEntity: EngineOpSource;
158
+ check: EngineOpSource;
159
+ /** Serves a check carrying no attribution, where there is no chain to lose. */
160
+ unattributedCheck?: EngineOpSource;
161
+ report: EngineOpSource;
162
+ archiveEntity: EngineOpSource;
163
+ };
164
+ reason: string;
165
+ };
166
+ export declare const describeResolution: (resolution: EngineResolution) => string;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeResolution = void 0;
4
+ const describeResolution = (resolution) => {
5
+ const { ops } = resolution;
6
+ // Only worth saying when the two differ; in customer-level mode one client serves both.
7
+ const unattributed = ops.unattributedCheck && ops.unattributedCheck !== ops.check ? ` (${ops.unattributedCheck} when unattributed)` : '';
8
+ return (`mode=${resolution.mode} check=${ops.check}${unattributed} report=${ops.report} ` +
9
+ `provisioning=${ops.upsertEntity} (${resolution.reason})`);
10
+ };
11
+ exports.describeResolution = describeResolution;