@inixiative/signet 0.11.1 → 0.12.1

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ A **Signet** is Kingdom's only grant for using an integration. It names the reso
6
6
 
7
7
  This package is the one implementation of that protocol, shared by Kingdom (which verifies) and Archive, Foundry and Oracle (which present):
8
8
 
9
- - `contracts`: the wire schemas: keys, lenses, proposals, pairing requests, delivered credentials, `describe` and `execute`, and the conversation frames.
9
+ - `contracts`: the wire schemas: keys, grants (operation picks, lens layers, terms), proposals, pairing requests, delivered credentials, `describe` and `execute`, and the conversation frames.
10
10
  - `proof`: create and verify DPoP proofs (ES256, nonce-bound, access-token-bound).
11
11
  - `files`: private 0600 JSON files for keys and credentials.
12
12
  - `client`:
@@ -26,7 +26,7 @@ const paired = await pairInstallation({
26
26
  terms: {
27
27
  name: 'Laptop Archive', lifecycle: 'ongoing', expiresAt: null, maxRequests: null, maxConcurrent: 4,
28
28
  owner: { ownerModel: 'Organization', organization: 'inixiative' },
29
- resources: [{ resourceKind: 'archiveLibrary', operations: ['sessions.write'] }],
29
+ resources: [{ resourceKind: 'archiveLibrary', operations: { picks: ['sessions.write'] } }],
30
30
  },
31
31
  onReview: ({ review }) => console.log(`Approve in Kingdom: ${review}`),
32
32
  confirmOwner: async ({ ownerName }) => confirm(`Pair with ${ownerName}?`),
@@ -50,7 +50,7 @@ import { awaitSignet, enrollSignet, generateSignetKey, requestSignet, saveCollec
50
50
  await writePrivateJson(keyFile, generateSignetKey());
51
51
  const { requestId, reviewCode } = await requestSignet(kingdom, keyFile, apiToken, {
52
52
  name: 'Research agent', integrationId, lifecycle: 'ongoing', expiresAt: null, maxRequests: null, maxConcurrent: 1,
53
- resources: [{ resourceId, operations: ['documents.read'] }],
53
+ resources: [{ resourceModel: 'IntegrationResource', integrationResourceId, operations: { picks: ['documents.read'] } }],
54
54
  });
55
55
  const collected = await awaitSignet(kingdom, keyFile, requestId); // or, for a granted Signet:
56
56
  // const collected = await enrollSignet(kingdom, keyFile, apiToken, { signetId, expectedRevision, name: 'Research agent' });
@@ -59,21 +59,37 @@ const signet = await SignetClient.fromFile(credentialFile);
59
59
  await signet.describe();
60
60
  ```
61
61
 
62
+ ## Grants
63
+
64
+ A Signet revision grants one row per resource (`grantResourceSchema`), false polymorphic on `resourceModel`:
65
+
66
+ - **`IntegrationResource`**: `full`, or `operations` (the resolved leaves; absent when `full`), `lens` (json-rules layers) and `terms` (per kind). `full` reaches everything the resource offers, now and as its catalog grows, capped live by the integration's baseline. It is a different axis from `Integration.full` / `Token.full`, which let a delegate act with its owner's whole authority.
67
+ - **`Project`**: a Kingdom project the Signet may be used in. Nothing else: no `full`, operations, lens or terms.
68
+
69
+ **Operation picks.** A provider's catalog is a flat list of dotted leaf keys per resource kind (`commits.read`, `events.pull_request.review_requested`). A party asks with `operationPickSchema`: `{ full: true }` or `{ picks }`, each pick a leaf or a prefix ending `.*` (`commits.*`, `events.pull_request.*`). `resolveOperationPicks(leaves, pick)` resolves picks to leaves once, when the grant is saved (a pick that reaches nothing throws `OperationPickError`, code `unknown_operation_pick`), so a later catalog addition reaches only `full` rows. `operationTree(leaves)` derives the nested tree a grant UI offers, every node with the pick that takes it. `narrowsOperations(inner, outer)`: a full `outer` contains everything, and a full `inner` under an `outer` that is not full is never a narrowing. `permitsOperation(scope, operation)` answers one operation; `scopeRefusal(scope, catalog, refusal)` runs a per-operation refusal over every catalog operation a row reaches (all of them when full, so re-run it when the catalog grows).
70
+
71
+ **Lens.** A grant's lens is a chain of json-rules layers over the integration's base lens for the resource's model, which Kingdom's catalog holds as code under `provider:kind`. A root `where` filters rows; picks and omits trim the shape; no lens means the base. A party writes one layer (`lensLayerSchema`, a json-rules narrowing without its parent); Kingdom stores it as a `StoredLens` record (`storedLensLayerSchema`) under the base, an approver's layer under the request's, so every layer ANDs and an approver can only narrow. This package depends on json-rules for types only, and checks only a layer's envelope: Kingdom validates (`validateNarrowing`), composes (`composeLens`) and applies (`projectRows`) the layers. The base's id is `baseLensId(provider, kind)`. The refusals below read a `GrantLens`: the grant's own layers plus `projectLens` of the composed chain (`projectionFilters`, `projectionShows`). A `where` runs over a row: `repositoryCallRow(input)` (`{ sha }`) for a repository call, `threadRow(threadId)` (`{ id }`) for a thread.
72
+
73
+ **Terms** are neither operations nor lens: how a holder takes part (`grantTermsSchema`, strict per kind; `grantTermsFitKind(kind, terms)`). Today only `threads` has terms.
74
+
75
+ What a party asks for (`signetResourceSchema` by id, `requestedResourceSchema` by id or by kind) carries picks, its own lens layer and terms (a request has no kind, so Kingdom checks `grantTermsFitKind` once it resolves the resource); what a holder reads (`signetGrantSchema`) is the stored row plus the resource's `kind`.
76
+
62
77
  ## Declared operations
63
78
 
64
- An integration whose operations Kingdom executes generically declares them here, one module per resource kind: `threads.ts` for a Foundry's `threads` resource, `archive.ts` for an Archive's `archiveLibrary`, `github.ts` for a GitHub `repository` (reads by object id; its lens pins a grant to commits, and a pinned grant reaches no tree or blob, since reading a commit never reached its tree). Each declares its operation enum, display names, the input each operation takes, its lens schema and the lens's narrowing. `declaredOperationSchema` and `operationInputSchemas` join them (operation names are unique across contracts), `operationInputKeys(operation)` gives the `access/execute` keys an operation takes besides `resourceId` and `limit`, and `operationInputFromAccess(operation, input)` picks and parses exactly those. Kingdom's catalog, its input check and its dispatch read these, so a new operation is added here first.
79
+ An integration whose operations Kingdom executes generically declares them here, one module per resource kind: `threads.ts` for a Foundry's `threads` resource, `archive.ts` for an Archive's `archiveLibrary`, `github.ts` for a GitHub `repository` (reads by object id). Each declares its operation enum, display names and the input each operation takes. `declaredOperationSchema` and `operationInputSchemas` join them (operation names are unique across contracts), `operationInputKeys(operation)` gives the `access/execute` keys an operation takes besides `resourceId` and `limit`, and `operationInputFromAccess(operation, input)` picks and parses exactly those. Kingdom's catalog, its input check and its dispatch read these, so a new operation is added here first.
65
80
 
66
- - **Archive** (`archiveLibrary`): `documents.read`, `documents.search`, `sessions.write`, `sessions.delete`. The lens is `{ documentIds?, fields? }` (`{}` reaches every document) and `narrowsArchiveLens` narrows it. `archiveSourceOperations` (`sessions.write`, `sessions.delete`) are a paired local Archive acting on its own sessions: Kingdom binds them to its `sourceId`, so their grant takes no lens. `archiveLensRefusal(operation, lens)` says why a grant can't carry a lens (search needs content over every document).
81
+ - **Archive** (`archiveLibrary`): `documents.read`, `documents.search`, `sessions.write`, `sessions.delete`. `archiveSourceOperations` (`sessions.write`, `sessions.delete`) are a paired local Archive acting on its own sessions: Kingdom binds them to its `sourceId`, so their grant takes no lens. `archiveLensRefusal(operation, lens)` says why a grant can't carry its lens (search needs content over every document: no `where`, `content` shown), and `archiveGrantRefusal(scope, lens)` checks a whole row.
82
+ - **GitHub** (`repository`): `commits.read`, `trees.read`, `blobs.read`. A grant pins commits with a `where` over the call's `{ sha }`; reading a commit never reaches its tree, so `repositoryLensRefusal(operation, lens)` (and `repositoryGrantRefusal(scope, lens)` for a row) refuses trees and blobs under a pinned grant.
67
83
 
68
84
  ## Agent conversations
69
85
 
70
86
  A conversation wraps a host Foundry's main thread (Kingdom `docs/AGENT-CONVERSATIONS.md`, Foundry `docs/reference/CONVERSATIONS.md`). A member is a Signet on the host Foundry's one `threads` resource; Kingdom admits each call and relays it over the host's Installation socket, and stores no conversation or message.
71
87
 
72
- - **Lens.** `signetLensSchema` is a union of strict shapes, `archiveLensSchema | threadsLensSchema`, because a resource grant names its resource by id with no kind. `resourceLensSchema(kind)` gives the shape a kind takes; Kingdom checks every grant with it against the kind it stores. A threads lens is `{ threads, view: 'own' | 'thread', delivery: 'note' | 'turn', outbound: 'review' | 'auto' }`, the terms defaulting to the narrowest value. `threads` is a selector, a union on `form`: v1 builds `{ form: 'ids', ids }` (1–100 thread ids); `withMember` and `tag` are named for standing Signets and fail to parse everywhere until a release adds them, so a new form never widens access silently. `narrowsSignetLens` is the rule for approving someone else's request: a subset of ids, `own` < `thread`, `note` < `turn`, `review` < `auto`.
73
- - **Join requests** may name a resource by kind: `requestSignet` takes `{ resourceKind: 'threads', operations, lens }`, and Kingdom resolves it before review.
74
- - **Per-entry reads.** `installationInquiries`, `installationSignets`, the socket snapshot and `describe` read each entry on its own. An entry this package can't read is dropped and listed in `dropped`, and the rest is kept. Inside a Signet, resources are read one by one: a resource with a lens this package can't read (a selector form from a newer Kingdom, a lens that doesn't fit its resource's kind) is dropped, and the Signet stays, so an installation never loses a held Signet, or mistakes a member for revoked, over one resource. The snapshot carries `hostedSignets` (members of this Foundry's conversations) and `hostedSignetRequests` (join requests aimed at its integrations); held Signets carry their `resources`.
75
- - **Host.** `InstallationSocket` takes `threads`, one handler per operation (`thread.read`, `thread.prompt`, `thread.floor`, `prompt.resolve`, `thread.subscribe`), answering `threadResult(...)` or `threadRefused(code)`. A frame with an unknown selector is refused `unknown_selector` before any handler runs. `thread.subscribe` answers with the member's view and the `journalSeq` it was taken at, and the subscription starts there: `appendThread(subscriptionId, entry)` holds entries pushed meanwhile and never sends one twice. Each append carries a dense per-subscription `seq` (a filtered view skips journal entries, so `journalSeq` can't reveal loss); an entry that can't be sent still spends its `seq`, so Kingdom sees the gap and the member resyncs. A subscription ends on `thread.unsubscribe`, when Kingdom stops re-asserting its lease (`thread.lease`; dropped 90 s after the last one, so one late 45 s sweep is survived), or when the socket drops.
76
- - **Member.** An outside agent holds its live views with `SignetSocket`: it authenticates as its Signet's presentation (`authenticateSignet`, a proof bound to `POST /api/v1/access/signetSocket` with `ath`), renews it before the five-minute token expires (`renewPresentation`), and opens one `accessThread` stream per thread: a snapshot, then appends, with Kingdom's `seq`/`resync` rules. Frames are handled in order, one open is in flight per stream, and Kingdom's close carries the reason: after a revision the stream reopens under the new lens, while the host is offline it retries with backoff, when the presentation lapsed the socket re-presents and reopens, and on revocation or expiry it ends (`onClosed`). Posts are separate `execute` calls (`thread.prompt`), each admitted and metered. A Foundry holding a conversation Signet opens the same streams over its Installation socket (`threadStreams`, `openThread`).
88
+ - **Terms.** A member's `threads` terms (`threadsTermsSchema`) are `{ view: 'own' | 'thread', delivery: 'note' | 'turn', outbound: 'review' | 'auto', floor: { mayTake } }`, each defaulting to its narrowest value (`threadsTerms(terms)` reads them so). Which threads a member reaches is the grant's lens (a `where` over the thread's `id`), which Kingdom applies at admission. `narrowsThreadsTerms` is the rule for approving someone else's request: `own` < `thread`, `note` < `turn`, `review` < `auto`, `floor.mayTake` only where asked. Kingdom relays the terms on every frame (`terms`).
89
+ - **Join requests** may name a resource by kind: `requestSignet` takes `{ resourceKind: 'threads', operations, lens, terms }`, and Kingdom resolves it before review.
90
+ - **Per-entry reads.** `installationInquiries`, `installationSignets`, the socket snapshot and `describe` read each entry on its own. An entry this package can't read is dropped and listed in `dropped`, and the rest is kept. Inside a Signet, resources are read one by one: a resource this package can't read (a term from a newer Kingdom, terms that don't fit its resource's kind) is dropped, and the Signet stays, so an installation never loses a held Signet, or mistakes a member for revoked, over one resource. The snapshot carries `hostedSignets` (members of this Foundry's conversations) and `hostedSignetRequests` (join requests aimed at its integrations); held Signets carry their `resources`.
91
+ - **Host.** `InstallationSocket` takes `threads`, one handler per operation (`thread.read`, `thread.prompt`, `thread.floor`, `prompt.resolve`, `thread.subscribe`), answering `threadResult(...)` or `threadRefused(code)`. A frame this package can't read is refused `invalid_request` before any handler runs. `thread.subscribe` answers with the member's view and the `journalSeq` it was taken at, and the subscription starts there: `appendThread(subscriptionId, entry)` holds entries pushed meanwhile and never sends one twice. Each append carries a dense per-subscription `seq` (a filtered view skips journal entries, so `journalSeq` can't reveal loss); an entry that can't be sent still spends its `seq`, so Kingdom sees the gap and the member resyncs. A subscription ends on `thread.unsubscribe`, when Kingdom stops re-asserting its lease (`thread.lease`; dropped 90 s after the last one, so one late 45 s sweep is survived), or when the socket drops.
92
+ - **Member.** An outside agent holds its live views with `SignetSocket`: it authenticates as its Signet's presentation (`authenticateSignet`, a proof bound to `POST /api/v1/access/signetSocket` with `ath`), renews it before the five-minute token expires (`renewPresentation`), and opens one `accessThread` stream per thread: a snapshot, then appends, with Kingdom's `seq`/`resync` rules. Frames are handled in order, one open is in flight per stream, and Kingdom's close carries the reason: after a revision the stream reopens under the new revision, while the host is offline it retries with backoff, when the presentation lapsed the socket re-presents and reopens, and on revocation or expiry it ends (`onClosed`). Posts are separate `execute` calls (`thread.prompt`), each admitted and metered. A Foundry holding a conversation Signet opens the same streams over its Installation socket (`threadStreams`, `openThread`).
77
93
 
78
94
  ```ts
79
95
  import { SignetClient, SignetSocket } from '@inixiative/signet';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/signet",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Present Kingdom Signets: the wire contracts, DPoP proofs, device pairing and a renewing client shared by Kingdom, Archive, Foundry and Oracle.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -35,7 +35,8 @@
35
35
  "devDependencies": {
36
36
  "@asteasolutions/zod-to-openapi": "^9.1.0",
37
37
  "@biomejs/biome": "2.5.2",
38
- "@inixiative/config": "^0.5.171",
38
+ "@inixiative/config": "^0.5.175",
39
+ "@inixiative/json-rules": "^3.16.0",
39
40
  "@types/bun": "1.4.4",
40
41
  "lefthook": "2.1.9",
41
42
  "typescript": "6.0.3",
@@ -53,6 +54,7 @@
53
54
  "access": "public"
54
55
  },
55
56
  "peerDependencies": {
56
- "zod": "^4.3.6"
57
+ "zod": "^4.3.6",
58
+ "@inixiative/json-rules": "^3.16.0"
57
59
  }
58
60
  }
package/src/archive.ts CHANGED
@@ -1,32 +1,28 @@
1
+ import type { Condition } from '@inixiative/json-rules';
1
2
  import { z } from 'zod';
3
+ import { type GrantLens, type LensLayer, projectionFilters, projectionShows } from './lens';
4
+ import { type OperationScope, scopeRefusal } from './operations';
2
5
 
3
6
  /**
4
7
  * Archive libraries (Kingdom `docs/kingdom.md`, Archive `docs/KINGDOM.md`). A hosted Archive is an
5
8
  * integration with one `archiveLibrary` resource. Kingdom admits each call through a Signet and runs
6
- * it against that Archive with the integration's credential. The Archive server never sees the lens,
7
- * so Kingdom applies it.
9
+ * it against that Archive with the integration's credential. The Archive server never sees the lens
10
+ * (json-rules layers over the library's base lens), so Kingdom applies it.
8
11
  */
9
12
  export const archiveLibraryResourceKind = 'archiveLibrary';
10
13
 
11
14
  /** An Archive session id: `archiveKey(snapshot)`, the digest of its source and session. */
12
15
  export const archiveDocumentIdSchema = z.string().regex(/^[a-f0-9]{64}$/);
13
- export const archiveFieldSchema = z.enum(['id', 'title', 'content', 'tags', 'createdAt']);
16
+ export const archiveFieldSchema = z.enum([
17
+ 'id',
18
+ 'title',
19
+ 'content',
20
+ 'tags',
21
+ 'projectId',
22
+ 'createdAt',
23
+ ]);
14
24
  export type ArchiveField = z.infer<typeof archiveFieldSchema>;
15
25
 
16
- /** Which documents and fields of an `archiveLibrary` a grant reaches; `{}` reaches all of them. */
17
- export const archiveLensSchema = z.strictObject({
18
- documentIds: z.array(archiveDocumentIdSchema).max(1000).optional(),
19
- fields: z.array(archiveFieldSchema).max(6).optional(),
20
- });
21
- export type ArchiveLens = z.infer<typeof archiveLensSchema>;
22
-
23
- const subset = <T>(granted: T[] | undefined, requested: T[] | undefined) =>
24
- !requested || (granted !== undefined && granted.every((item) => requested.includes(item)));
25
-
26
- /** `granted` reaches no document or field `requested` doesn't. */
27
- export const narrowsArchiveLens = (granted: ArchiveLens, requested: ArchiveLens): boolean =>
28
- subset(granted.documentIds, requested.documentIds) && subset(granted.fields, requested.fields);
29
-
30
26
  /** The operations an `archiveLibrary` resource carries. */
31
27
  export const archiveOperationSchema = z.enum([
32
28
  'documents.read',
@@ -54,7 +50,7 @@ export const archiveSourceOperations = [
54
50
  export const isArchiveSourceOperation = (operation: ArchiveOperation) =>
55
51
  (archiveSourceOperations as readonly ArchiveOperation[]).includes(operation);
56
52
 
57
- const fieldsSchema = z.array(archiveFieldSchema).min(1).max(5);
53
+ const fieldsSchema = z.array(archiveFieldSchema).min(1).max(archiveFieldSchema.options.length);
58
54
 
59
55
  /**
60
56
  * The input each Archive operation takes. `access/execute` carries the same fields beside
@@ -90,14 +86,54 @@ export const archiveInputSchemas = {
90
86
  'sessions.delete': z.strictObject({ documentId: archiveDocumentIdSchema }),
91
87
  } as const satisfies Record<ArchiveOperation, z.ZodObject>;
92
88
 
93
- /** Why a grant of `operation` can't carry `lens`, or null when it can. */
94
- export const archiveLensRefusal = (operation: ArchiveOperation, lens: ArchiveLens) => {
95
- if (isArchiveSourceOperation(operation) && Object.keys(lens).length > 0)
96
- return 'Writing or deleting sessions takes no lens';
89
+ /** Why a grant of `operation` can't carry `lens`, or null when it can. No layers is the base. */
90
+ export const archiveLensRefusal = (operation: ArchiveOperation, lens: GrantLens) => {
91
+ if (lens.layers.length === 0) return null;
92
+ if (isArchiveSourceOperation(operation)) return 'Writing or deleting sessions takes no lens';
97
93
  if (
98
94
  operation === 'documents.search' &&
99
- (lens.documentIds || (lens.fields && !lens.fields.includes('content')))
95
+ (projectionFilters(lens.projection) || !projectionShows(lens.projection, 'content'))
100
96
  )
101
97
  return 'Search needs a lens that shows content and is not limited to chosen documents';
102
98
  return null;
103
99
  };
100
+
101
+ type ArchiveInput<O extends ArchiveOperation> = z.infer<(typeof archiveInputSchemas)[O]>;
102
+
103
+ /**
104
+ * What one call filters on, as a json-rules condition over the Archive document, or undefined when
105
+ * it filters on nothing. Kingdom gates it with `validateRuleInLens` against the grant's composed
106
+ * lens: a filter on a field the lens hides (`projectId`, a `tag`) would say which documents carry a
107
+ * value the holder can't see, and is refused with the lens's own code.
108
+ */
109
+ export const archiveCallCondition = <O extends ArchiveOperation>(
110
+ _operation: O,
111
+ input: ArchiveInput<O>,
112
+ ): Condition | undefined => {
113
+ const rules: Condition[] = [
114
+ ...('projectId' in input && input.projectId !== undefined
115
+ ? [{ field: 'projectId', operator: 'equals' as const, value: input.projectId }]
116
+ : []),
117
+ ...('tag' in input && input.tag !== undefined
118
+ ? [{ field: 'tags', operator: 'contains' as const, value: input.tag }]
119
+ : []),
120
+ ];
121
+ return rules.length === 0 ? undefined : rules.length === 1 ? rules[0] : { all: rules };
122
+ };
123
+
124
+ /**
125
+ * The fields one call asks for, as a lens layer that picks them, or undefined when it asks for none.
126
+ * Kingdom validates it under the grant's composed lens (`validateNarrowing`): a field the lens hides
127
+ * is refused there.
128
+ */
129
+ export const archiveCallNarrowing = <O extends ArchiveOperation>(
130
+ _operation: O,
131
+ input: ArchiveInput<O>,
132
+ ): LensLayer | undefined =>
133
+ 'fields' in input && input.fields?.length ? { root: { picks: [...input.fields] } } : undefined;
134
+
135
+ /** `archiveLensRefusal` over every operation a grant row reaches, `full` meaning all of them. */
136
+ export const archiveGrantRefusal = (scope: OperationScope, lens: GrantLens) =>
137
+ scopeRefusal(scope, archiveOperationSchema.options, (operation) =>
138
+ archiveLensRefusal(operation, lens),
139
+ );
package/src/client.ts CHANGED
@@ -409,7 +409,7 @@ export const enrollSignet = async (
409
409
 
410
410
  /**
411
411
  * The Signets held by this installation's integrations, and those issued on them (`hostedSignets`).
412
- * Read per entry: a Signet whose lens this package can't read is dropped and listed in `dropped`.
412
+ * Read per entry: a Signet whose grants this package can't read is dropped and listed in `dropped`.
413
413
  */
414
414
  export const installationSignets = (
415
415
  url: string,
@@ -610,7 +610,7 @@ export class SignetClient {
610
610
  { signal, timeoutMs: options.timeoutMs },
611
611
  );
612
612
  }
613
- /** Read per entry: a resource whose lens this package can't read is dropped, listed in `dropped`. */
613
+ /** Read per entry: a resource whose lens or terms this package can't read is dropped, listed in `dropped`. */
614
614
  async describe(options: SignetRequestOptions = {}) {
615
615
  return parseSignetDescription(
616
616
  await this.post('describe', { signetId: this.signetId }, undefined, options),
package/src/contracts.ts CHANGED
@@ -3,41 +3,33 @@ import {
3
3
  archiveDocumentIdSchema,
4
4
  archiveFieldSchema,
5
5
  archiveInputSchemas,
6
- archiveLensSchema,
7
- archiveLibraryResourceKind,
8
6
  archiveOperationSchema,
9
- narrowsArchiveLens,
10
7
  } from './archive';
8
+ import { githubInputSchemas, githubOperationSchema, gitObjectIdSchema } from './github';
11
9
  import {
12
- eventsInputSchemas,
13
- eventsLensSchema,
14
- eventsOperationSchema,
15
- eventsResourceKind,
16
- narrowsEventsLens,
17
- } from './events';
18
- import {
19
- githubInputSchemas,
20
- githubOperationSchema,
21
- gitObjectIdSchema,
22
- narrowsRepositoryLens,
23
- repositoryLensSchema,
24
- repositoryResourceKind,
25
- } from './github';
10
+ grantTermsFitKind,
11
+ grantTermsSchema,
12
+ requestedResourceSchema,
13
+ type SignetGrant,
14
+ signetGrantSchema,
15
+ signetResourceSchema,
16
+ } from './grants';
17
+ import { storedBaseLensSchema, storedLensLayersSchema } from './lens';
26
18
  import {
27
19
  foundryIdSchema,
28
- narrowsThreadsLens,
29
20
  threadFloorActionSchema,
30
21
  threadIdSchema,
31
22
  threadInputSchemas,
32
23
  threadOperationSchema,
33
- threadsLensSchema,
34
- threadsResourceKind,
35
24
  threadTextLimit,
36
25
  } from './threads';
37
26
 
38
27
  export * from './archive';
39
28
  export * from './events';
40
29
  export * from './github';
30
+ export * from './grants';
31
+ export * from './lens';
32
+ export * from './operations';
41
33
  export * from './threads';
42
34
 
43
35
  const token43 = /^[A-Za-z0-9_-]{43}$/;
@@ -88,83 +80,12 @@ export const signetKeySchema = publicClientKeySchema.extend({
88
80
  export type PublicClientKey = z.infer<typeof publicClientKeySchema>;
89
81
  export type SignetKey = z.infer<typeof signetKeySchema>;
90
82
 
91
- /** Every other resource kind takes no lens. */
92
- export const emptyLensSchema = z.strictObject({});
93
-
94
- /**
95
- * A grant's lens. `signetResourceSchema` names a resource by id with no `kind`, so the contract can
96
- * only hold a union of the strict shapes; which shape a grant may carry depends on its resource's
97
- * kind, and Kingdom checks that with `resourceLensSchema` against the kind it stores (review R9).
98
- * `{}` parses as the empty Archive lens: "no lens", valid for every kind but `threads`.
99
- */
100
- export const signetLensSchema = z.union([
101
- archiveLensSchema,
102
- threadsLensSchema,
103
- repositoryLensSchema,
104
- eventsLensSchema,
105
- ]);
106
- export type SignetLens = z.infer<typeof signetLensSchema>;
107
-
108
- /** The lens shape a resource of `kind` takes. */
109
- export const resourceLensSchema = (kind: string) =>
110
- kind === archiveLibraryResourceKind
111
- ? archiveLensSchema
112
- : kind === threadsResourceKind
113
- ? threadsLensSchema
114
- : kind === repositoryResourceKind
115
- ? repositoryLensSchema
116
- : kind === eventsResourceKind
117
- ? eventsLensSchema
118
- : emptyLensSchema;
119
-
120
- /**
121
- * `granted` reaches nothing `requested` doesn't, for lenses of the same resource. Approving someone
122
- * else's request may only narrow; an owner revising their own grant may widen (review U3). A lens of
123
- * another shape than the request's is refused.
124
- */
125
- export const narrowsSignetLens = (granted: SignetLens, requested: SignetLens): boolean => {
126
- if ('threads' in granted || 'threads' in requested)
127
- return 'threads' in granted && 'threads' in requested && narrowsThreadsLens(granted, requested);
128
- const isEvents = (lens: SignetLens) => 'where' in lens || 'select' in lens || 'name' in lens;
129
- if (isEvents(granted) || isEvents(requested)) {
130
- const eventsGranted = eventsLensSchema.safeParse(granted);
131
- const eventsRequested = eventsLensSchema.safeParse(requested);
132
- return (
133
- eventsGranted.success &&
134
- eventsRequested.success &&
135
- narrowsEventsLens(eventsGranted.data, eventsRequested.data)
136
- );
137
- }
138
- if ('commits' in granted || 'commits' in requested) {
139
- const repositoryGranted = repositoryLensSchema.safeParse(granted);
140
- const repositoryRequested = repositoryLensSchema.safeParse(requested);
141
- return (
142
- repositoryGranted.success &&
143
- repositoryRequested.success &&
144
- narrowsRepositoryLens(repositoryGranted.data, repositoryRequested.data)
145
- );
146
- }
147
- const archiveGranted = archiveLensSchema.safeParse(granted);
148
- const archiveRequested = archiveLensSchema.safeParse(requested);
149
- return (
150
- archiveGranted.success &&
151
- archiveRequested.success &&
152
- narrowsArchiveLens(archiveGranted.data, archiveRequested.data)
153
- );
154
- };
155
-
156
- export const signetResourceSchema = z.strictObject({
157
- resourceId: z.uuid(),
158
- operations: z.array(z.string().min(1).max(80)).min(1).max(100),
159
- lens: signetLensSchema.default({}),
160
- });
161
83
  export const signetRecipientSchema = z.discriminatedUnion('recipientModel', [
162
84
  z.strictObject({ recipientModel: z.literal('User'), userId: z.uuid() }),
163
85
  z.strictObject({ recipientModel: z.literal('Integration'), integrationId: z.uuid() }),
164
86
  ]);
165
87
  export const signetLifecycleSchema = z.enum(['request', 'task', 'ongoing']);
166
88
  export type SignetRecipient = z.infer<typeof signetRecipientSchema>;
167
- export type SignetResource = z.infer<typeof signetResourceSchema>;
168
89
 
169
90
  export const signetEnrollmentSchema = z.strictObject({
170
91
  signetId: z.uuid(),
@@ -206,18 +127,6 @@ const signetTerms = {
206
127
  /** A local Foundry, Archive or Oracle known to Kingdom by its key. */
207
128
  export const installationKindSchema = z.enum(['foundry', 'archive', 'oracle']);
208
129
  export type InstallationKind = z.infer<typeof installationKindSchema>;
209
- /**
210
- * A resource asked for by kind, and optionally name, when the asker does not know its id: an
211
- * Installation registering, or a party asking to join a Foundry's `threads` (one per Foundry, so the
212
- * integration id names it). Kingdom resolves it to the resource's id before anyone reviews it.
213
- */
214
- export const requestedResourceKindSchema = z.strictObject({
215
- resourceKind: z.string().min(1).max(80),
216
- resourceName: z.string().trim().min(1).max(120).optional(),
217
- operations: z.array(z.string().min(1).max(80)).min(1).max(100),
218
- lens: signetLensSchema.default({}),
219
- });
220
- export const requestedResourceSchema = z.union([signetResourceSchema, requestedResourceKindSchema]);
221
130
  /** What a party asks for on an integration: resources by id or by kind (review R8). */
222
131
  export const signetProposalSchema = z.strictObject({
223
132
  ...signetTerms,
@@ -260,7 +169,6 @@ export const signetRequestResponseSchema = z.object({
260
169
  export type SignetProposal = z.infer<typeof signetProposalSchema>;
261
170
  export type RegistrationTerms = z.infer<typeof registrationTermsSchema>;
262
171
  export type RequestedOwner = z.infer<typeof requestedOwnerSchema>;
263
- export type RequestedResource = z.input<typeof requestedResourceSchema>;
264
172
  export type RegistrationRequest = z.input<typeof registrationRequestSchema>;
265
173
  export type SignetRequest = z.infer<typeof signetRequestSchema>;
266
174
  export type SignetRequestResponse = z.infer<typeof signetRequestResponseSchema>;
@@ -330,18 +238,7 @@ export const installationInquiriesResponseSchema = z.object({
330
238
  });
331
239
  export const inquiryReferenceSchema = z.strictObject({ inquiryId: z.uuid() });
332
240
 
333
- /** One resource a Signet reaches, with its stored kind, so its lens can be checked against it. */
334
- export const signetGrantSchema = z.object({
335
- resourceId: z.uuid(),
336
- kind: z.string().min(1).max(80),
337
- operations: z.array(z.string().min(1).max(80)),
338
- lens: signetLensSchema,
339
- });
340
- export type SignetGrant = z.infer<typeof signetGrantSchema>;
341
241
  const grantsSchema = z.array(signetGrantSchema);
342
- /** Whether a grant's lens is the shape its resource's kind takes. */
343
- export const grantLensFitsKind = (grant: Pick<SignetGrant, 'kind' | 'lens'>) =>
344
- resourceLensSchema(grant.kind).safeParse(grant.lens).success;
345
242
 
346
243
  /** Who a Signet is issued to. Recipients run broad (2026-10-18); Kingdom widens issuing in K0. */
347
244
  export const signetRecipientModelSchema = z.enum([
@@ -383,6 +280,8 @@ export const hostedSignetSchema = z.object({
383
280
  id: z.uuid(),
384
281
  displayName: z.string(),
385
282
  }),
283
+ /** Each base lens the rows' `lens` layers compose under, keyed by `baseLensId(provider, kind)`. */
284
+ baseLenses: z.record(z.string(), storedBaseLensSchema),
386
285
  resources: grantsSchema,
387
286
  });
388
287
  export type InstallationSignet = z.infer<typeof installationSignetSchema>;
@@ -403,7 +302,8 @@ export const signetDescriptionResourceSchema = z.object({
403
302
  name: z.string(),
404
303
  kind: z.string(),
405
304
  integrationId: z.uuid(),
406
- lens: signetLensSchema,
305
+ lens: storedLensLayersSchema.optional(),
306
+ terms: grantTermsSchema.optional(),
407
307
  });
408
308
  export const signetDescriptionSchema = z.object({
409
309
  signetId: z.uuid(),
@@ -498,14 +398,12 @@ export const declaredOperationSchema = z.enum([
498
398
  ...threadOperationSchema.options,
499
399
  ...archiveOperationSchema.options,
500
400
  ...githubOperationSchema.options,
501
- ...eventsOperationSchema.options,
502
401
  ]);
503
402
  export type DeclaredOperation = z.infer<typeof declaredOperationSchema>;
504
403
  export const operationInputSchemas = {
505
404
  ...threadInputSchemas,
506
405
  ...archiveInputSchemas,
507
406
  ...githubInputSchemas,
508
- ...eventsInputSchemas,
509
407
  } as const satisfies Record<DeclaredOperation, z.ZodObject>;
510
408
  export type OperationInput<O extends DeclaredOperation> = z.infer<
511
409
  (typeof operationInputSchemas)[O]
@@ -552,7 +450,7 @@ const entryId = (value: unknown) => {
552
450
  };
553
451
  /**
554
452
  * Parse each entry on its own and drop the ones that don't parse. A Kingdom ahead of this package
555
- * (a selector form it doesn't know, a new field shape) then costs only those entries: never the
453
+ * (a term it doesn't know, a new field shape) then costs only those entries: never the
556
454
  * Installation's other grants or its own held Signets. A missing list reads as empty, reported.
557
455
  */
558
456
  export function parseEntries<T>(
@@ -577,12 +475,14 @@ export function parseEntries<T>(
577
475
  });
578
476
  }
579
477
 
580
- const lensFits = (grant: Pick<SignetGrant, 'kind' | 'lens'>) =>
581
- grantLensFitsKind(grant) ? null : `a ${grant.kind} resource carries a lens of another shape`;
478
+ const termsFit = (grant: SignetGrant) =>
479
+ grant.resourceModel === 'Project' || grantTermsFitKind(grant.kind, grant.terms)
480
+ ? null
481
+ : `a ${grant.kind} resource carries terms of another kind`;
582
482
 
583
483
  /**
584
484
  * Signets read per entry, and their resources per entry inside them: a resource this package can't
585
- * read (a newer lens, a lens that doesn't fit its kind) is dropped and reported, and the Signet is
485
+ * read (a newer term, terms that don't fit its kind) is dropped and reported, and the Signet is
586
486
  * kept, so the Installation never loses a held Signet (or mistakes a member for revoked) over one
587
487
  * resource.
588
488
  */
@@ -602,7 +502,7 @@ function parseSignetsWithGrants<S extends z.ZodObject<{ resources: typeof grants
602
502
  entry.resources,
603
503
  signetGrantSchema,
604
504
  dropped,
605
- lensFits,
505
+ termsFit,
606
506
  ),
607
507
  }) as z.infer<S>,
608
508
  );
@@ -654,7 +554,7 @@ export function parseInstallationSnapshot(value: unknown): InstallationSnapshot
654
554
 
655
555
  export type ParsedSignetDescription = SignetDescription & { dropped: DroppedEntry[] };
656
556
 
657
- /** `access/describe`, per entry: a resource whose lens doesn't parse, or doesn't fit its kind, is dropped. */
557
+ /** `access/describe`, per entry: a resource whose lens or terms don't parse, or don't fit its kind, is dropped. */
658
558
  export function parseSignetDescription(value: unknown): ParsedSignetDescription {
659
559
  const dropped: DroppedEntry[] = [];
660
560
  const record = z
@@ -668,7 +568,10 @@ export function parseSignetDescription(value: unknown): ParsedSignetDescription
668
568
  operation.resources,
669
569
  signetDescriptionResourceSchema,
670
570
  dropped,
671
- (resource) => (grantLensFitsKind(resource) ? null : 'lens does not fit the resource kind'),
571
+ (resource) =>
572
+ grantTermsFitKind(resource.kind, resource.terms)
573
+ ? null
574
+ : 'terms do not fit the resource kind',
672
575
  ),
673
576
  }));
674
577
  return { ...signetDescriptionSchema.parse({ ...record, operations }), dropped };
package/src/events.ts CHANGED
@@ -2,14 +2,11 @@ import { z } from 'zod';
2
2
 
3
3
  /**
4
4
  * Integration events (Kingdom `docs/INTEGRATION-EVENTS.md`, Aron 2026-11-01). An integration that
5
- * receives its provider's events (a webhook endpoint, or a poll where the provider has none) has one
6
- * `events` resource: its feed. A Signet grant of `events.subscribe` on it is a **channel**: a
7
- * json-rules `where` over the event and an optional `select` of what the holder receives. The holder
8
- * reads its Signet to see the channels it has, and subscribes to any or all of them; Kingdom matches
9
- * each arriving event against the integration's channels the same way. Live only: a channel nobody
10
- * holds open drops the event.
5
+ * receives its provider's events (a webhook endpoint, or a poll where the provider has none) fans
6
+ * each one out to the Signet grants that reach it: the event kinds are operation leaves under
7
+ * `events.`, picked like any other, and the grant's lens layers filter and trim the event. Live
8
+ * only: a channel nobody holds open drops the event.
11
9
  */
12
- export const eventsResourceKind = 'events';
13
10
 
14
11
  /** A provider identity an event names: a login or handle, and the provider's id when it gives one. */
15
12
  export const eventPartySchema = z.strictObject({
@@ -49,7 +46,21 @@ export const integrationEventSchema = z.strictObject({
49
46
  });
50
47
  export type IntegrationEvent = z.infer<typeof integrationEventSchema>;
51
48
 
52
- /** The envelope fields a channel may `select`. */
49
+ /**
50
+ * The integration itself as a model: account-level events that belong to no repository, channel or
51
+ * team (a person's mentions, review requests and assignments from GitHub's notifications, a Slack DM)
52
+ * hang off it. Each integration that receives events has one `account` resource.
53
+ */
54
+ export const accountResourceKind = 'account';
55
+
56
+ /**
57
+ * An event kind's operation leaf: `events.<provider event>.<action>`, mirroring the provider's own
58
+ * taxonomy (Aron, 2026-11-01). A grant picks it like any operation, a prefix (`events.pull_request.*`)
59
+ * or everything (`full`).
60
+ */
61
+ export const eventOperationKey = (type: IntegrationEvent['type']) => `events.${type}`;
62
+
63
+ /** The envelope's fields, for the base event lens a provider's catalog declares over the event model. */
53
64
  export const integrationEventFieldSchema = z.enum([
54
65
  'eventId',
55
66
  'integrationId',
@@ -68,83 +79,22 @@ export const integrationEventFieldSchema = z.enum([
68
79
  export type IntegrationEventField = z.infer<typeof integrationEventFieldSchema>;
69
80
 
70
81
  /**
71
- * A channel: `where` is a json-rules condition over the event (Kingdom validates it with
72
- * json-rules against `integrationEventSchema`'s fields; this package only carries it as JSON), a
73
- * positive condition, so a grant without one matches every event. `select` is the overlay
74
- * restricting what the holder receives; `eventId` and `type` are always sent. The `where` may test
75
- * fields the `select` leaves out. `name` is what the holder sees when it reads its channels.
82
+ * One append on a channel's stream. `eventId` and `type` ride outside the projected body, so the
83
+ * grant's lens trimming can never drop what a holder needs to dedupe and route; `event` is the
84
+ * envelope as the grant's lens layers project it (json-rules `projectRows`).
76
85
  */
77
- export const eventsLensSchema = z.strictObject({
78
- name: z.string().min(1).max(80).optional(),
79
- where: z.record(z.string(), z.unknown()).optional(),
80
- select: z.array(integrationEventFieldSchema).min(1).max(20).optional(),
86
+ export const accessEventsAppendSchema = z.strictObject({
87
+ eventId: integrationEventSchema.shape.eventId,
88
+ type: integrationEventSchema.shape.type,
89
+ event: z.record(z.string(), z.unknown()),
81
90
  });
82
- export type EventsLens = z.infer<typeof eventsLensSchema>;
83
-
84
- /** What a holder receives from a channel: the event, cut to the channel's `select`. */
85
- export const selectedEventSchema = integrationEventSchema
86
- .partial()
87
- .required({ eventId: true, type: true });
88
- export type SelectedEvent = z.infer<typeof selectedEventSchema>;
89
-
90
- /** The event as a channel's `select` lets it through; `eventId` and `type` always go. */
91
- export const selectEvent = (lens: EventsLens, event: IntegrationEvent): SelectedEvent => {
92
- if (!lens.select) return event;
93
- const fields = new Set<IntegrationEventField>([...lens.select, 'eventId', 'type']);
94
- return Object.fromEntries(
95
- Object.entries(event).filter(([key]) => fields.has(key as IntegrationEventField)),
96
- ) as SelectedEvent;
97
- };
98
-
99
- /** JSON with object keys sorted at every depth: a condition read back from jsonb reorders them. */
100
- const canonical = (value: unknown): unknown =>
101
- Array.isArray(value)
102
- ? value.map(canonical)
103
- : value !== null && typeof value === 'object'
104
- ? Object.fromEntries(
105
- Object.keys(value)
106
- .sort()
107
- .map((key) => [key, canonical((value as Record<string, unknown>)[key])]),
108
- )
109
- : value;
110
- const sameJson = (a: unknown, b: unknown) =>
111
- JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
112
-
113
- /**
114
- * `granted` reaches nothing `requested` doesn't. Conservative and structural, since this package
115
- * doesn't evaluate json-rules: the granted `where` is the requested one, or `{all: [...]}` holding
116
- * it (the requested condition plus more); the granted `select` is within the requested one.
117
- */
118
- export const narrowsEventsLens = (granted: EventsLens, requested: EventsLens) => {
119
- const whereNarrows =
120
- requested.where === undefined ||
121
- sameJson(granted.where, requested.where) ||
122
- (typeof granted.where === 'object' &&
123
- granted.where !== null &&
124
- !Array.isArray(granted.where) &&
125
- Array.isArray((granted.where as { all?: unknown }).all) &&
126
- (granted.where as { all: unknown[] }).all.some((part) => sameJson(part, requested.where)));
127
- const selectNarrows =
128
- !requested.select ||
129
- (!!granted.select && granted.select.every((field) => requested.select?.includes(field)));
130
- return whereNarrows && selectNarrows;
131
- };
132
-
133
- /** The operation an `events` resource carries: hold a channel open. It takes no input. */
134
- export const eventsOperationSchema = z.enum(['events.subscribe']);
135
- export type EventsOperation = z.infer<typeof eventsOperationSchema>;
136
- export const eventsOperationNames: Record<EventsOperation, string> = {
137
- 'events.subscribe': "Receive this integration's events as they happen, filtered by the channel",
138
- };
139
- export const eventsInputSchemas = {
140
- 'events.subscribe': z.strictObject({}),
141
- } as const satisfies Record<EventsOperation, z.ZodObject>;
91
+ export type AccessEventsAppend = z.infer<typeof accessEventsAppendSchema>;
142
92
 
143
93
  /**
144
94
  * A channel's live stream is a Kingdom data stream, like a thread's (`accessThread`): its family is
145
95
  * the operationId of Kingdom's snapshot route `GET /api/v1/access/events/{resourceId}/{signetId}`,
146
96
  * and its name that route's channel key (path fields sorted). The snapshot is always empty: there is
147
- * no backlog. Each append's payload is one `SelectedEvent`.
97
+ * no backlog. Each append's payload is one `AccessEventsAppend`.
148
98
  */
149
99
  export const accessEventsStreamFamily = 'accessEvents';
150
100
  export const accessEventsStreamName = (stream: { signetId: string; resourceId: string }) =>
package/src/github.ts CHANGED
@@ -1,31 +1,19 @@
1
1
  import { z } from 'zod';
2
+ import { type GrantLens, projectionFilters } from './lens';
3
+ import { type OperationScope, scopeRefusal } from './operations';
2
4
 
3
5
  /**
4
6
  * GitHub repositories (Kingdom `docs/OPERATOR-ACCESS.md` §9). A GitHub integration is one GitHub
5
7
  * account; each repository it reaches is a `repository` resource, named `owner/name`. Kingdom admits
6
8
  * each call through a Signet and reads the repository with the integration's credential, so the
7
- * lens is applied by Kingdom.
9
+ * lens is applied by Kingdom. A grant pins commits with a `where` over the call's `{ sha }`; reading a
10
+ * commit never reaches its tree, so a pinned grant reaches no tree or blob.
8
11
  */
9
12
  export const repositoryResourceKind = 'repository';
10
13
 
11
14
  /** A Git object id: an immutable commit, tree or blob SHA-1. */
12
15
  export const gitObjectIdSchema = z.string().regex(/^[a-f0-9]{40}$/);
13
16
 
14
- /**
15
- * Which commits of a `repository` a grant reaches; `{}` reaches the whole repository. A grant pinned
16
- * to commits reads only those commits: reading a commit never reaches its tree, so a pinned grant
17
- * reaches no tree or blob.
18
- */
19
- export const repositoryLensSchema = z.strictObject({
20
- commits: z.array(gitObjectIdSchema).min(1).max(1000).optional(),
21
- });
22
- export type RepositoryLens = z.infer<typeof repositoryLensSchema>;
23
-
24
- /** `granted` reaches no commit `requested` doesn't. */
25
- export const narrowsRepositoryLens = (granted: RepositoryLens, requested: RepositoryLens) =>
26
- !requested.commits ||
27
- (granted.commits?.every((commit) => requested.commits?.includes(commit)) ?? false);
28
-
29
17
  /** The operations a `repository` resource carries. */
30
18
  export const githubOperationSchema = z.enum(['commits.read', 'trees.read', 'blobs.read']);
31
19
  export type GithubOperation = z.infer<typeof githubOperationSchema>;
@@ -47,18 +35,16 @@ export const githubInputSchemas = {
47
35
  } as const satisfies Record<GithubOperation, z.ZodObject>;
48
36
 
49
37
  /** Why a grant of `operation` can't carry `lens`, or null when it can. */
50
- export const repositoryLensRefusal = (operation: GithubOperation, lens: RepositoryLens) =>
51
- lens.commits && operation !== 'commits.read'
38
+ export const repositoryLensRefusal = (operation: GithubOperation, lens: GrantLens) =>
39
+ projectionFilters(lens.projection) && operation !== 'commits.read'
52
40
  ? 'A grant pinned to commits reads only those commits; trees and blobs need an unpinned grant'
53
41
  : null;
54
42
 
55
- /** Whether every lens a call is admitted under reaches `objectId` for `operation`. */
56
- export const lensesSelectObject = (
57
- lenses: RepositoryLens[],
58
- operation: GithubOperation,
59
- objectId: string,
60
- ) =>
61
- lenses.every(
62
- (lens) =>
63
- !repositoryLensRefusal(operation, lens) && (!lens.commits || lens.commits.includes(objectId)),
43
+ /** `repositoryLensRefusal` over every operation a grant row reaches, `full` meaning all of them. */
44
+ export const repositoryGrantRefusal = (scope: OperationScope, lens: GrantLens) =>
45
+ scopeRefusal(scope, githubOperationSchema.options, (operation) =>
46
+ repositoryLensRefusal(operation, lens),
64
47
  );
48
+
49
+ /** The row a repository grant's `where` is evaluated over: the call's object, as `sha`. */
50
+ export const repositoryCallRow = (input: { objectId: string }) => ({ sha: input.objectId });
package/src/grants.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { z } from 'zod';
2
+ import { lensLayerSchema, storedLensLayersSchema } from './lens';
3
+ import { operationPickSchema, resolvedOperationsSchema } from './operations';
4
+ import { threadsResourceKind, threadsTermsSchema } from './threads';
5
+
6
+ /**
7
+ * What a Signet revision grants, one row per resource (Aron, 2026-11-01). False polymorphism on
8
+ * `resourceModel`: an `IntegrationResource` row carries its operations (`full`, or the leaves its
9
+ * picks resolved to), its lens layers and its per-kind terms; a `Project` row names a Kingdom project
10
+ * the Signet may be used in, and nothing else. Artifacts may join later.
11
+ */
12
+ export const grantResourceModelSchema = z.enum(['IntegrationResource', 'Project']);
13
+ export type GrantResourceModel = z.infer<typeof grantResourceModelSchema>;
14
+
15
+ /**
16
+ * Per-kind terms that are neither operations nor lens: how a holder takes part. Strict per kind. A
17
+ * request names its resource by id with no kind, so Kingdom checks `grantTermsFitKind` once it has
18
+ * resolved the resource.
19
+ */
20
+ export const grantTermsSchema = z.strictObject({ threads: threadsTermsSchema.optional() });
21
+ export type GrantTerms = z.infer<typeof grantTermsSchema>;
22
+
23
+ /** The resource kind each terms key belongs to. */
24
+ const termsKinds = { threads: threadsResourceKind } as const satisfies Record<
25
+ keyof GrantTerms,
26
+ string
27
+ >;
28
+ /** Whether `terms` only carry keys that belong to a resource of `kind`. */
29
+ export const grantTermsFitKind = (kind: string, terms: GrantTerms | undefined) =>
30
+ Object.entries(terms ?? {}).every(
31
+ ([key, value]) => value === undefined || termsKinds[key as keyof GrantTerms] === kind,
32
+ );
33
+
34
+ /** A threads grant's terms, every one it leaves out at its narrowest. */
35
+ export const threadsTerms = (terms: z.input<typeof grantTermsSchema> | undefined) =>
36
+ threadsTermsSchema.parse(terms?.threads ?? {});
37
+
38
+ const integrationResourceShape = {
39
+ resourceModel: z.literal('IntegrationResource'),
40
+ integrationResourceId: z.uuid(),
41
+ /** Everything the resource offers, now and as its catalog grows, capped live by the baseline. */
42
+ full: z.boolean(),
43
+ /** The resolved leaves; absent when `full`. */
44
+ operations: resolvedOperationsSchema.optional(),
45
+ /** The layers under the base lens; absent means the base. */
46
+ lens: storedLensLayersSchema.optional(),
47
+ terms: grantTermsSchema.optional(),
48
+ };
49
+ const fullOrOperations = (
50
+ row: { full: boolean; operations?: string[] },
51
+ context: z.RefinementCtx,
52
+ ) => {
53
+ if (row.full && row.operations !== undefined)
54
+ context.addIssue({
55
+ code: 'custom',
56
+ path: ['operations'],
57
+ message: 'A full grant lists no operations',
58
+ });
59
+ if (!row.full && row.operations === undefined)
60
+ context.addIssue({
61
+ code: 'custom',
62
+ path: ['operations'],
63
+ message: 'A grant that is not full lists its operations',
64
+ });
65
+ };
66
+
67
+ export const integrationResourceGrantSchema = z
68
+ .strictObject(integrationResourceShape)
69
+ .superRefine(fullOrOperations);
70
+ export const projectGrantSchema = z.strictObject({
71
+ resourceModel: z.literal('Project'),
72
+ projectId: z.uuid(),
73
+ });
74
+ /** One stored grant row, as Kingdom writes it on a revision. */
75
+ export const grantResourceSchema = z.discriminatedUnion('resourceModel', [
76
+ integrationResourceGrantSchema,
77
+ projectGrantSchema,
78
+ ]);
79
+ export type GrantResource = z.infer<typeof grantResourceSchema>;
80
+ export type IntegrationResourceGrant = z.infer<typeof integrationResourceGrantSchema>;
81
+ export type ProjectGrant = z.infer<typeof projectGrantSchema>;
82
+
83
+ /**
84
+ * A grant as a holder reads it: the stored row, plus the resource's `kind` so its terms can be read.
85
+ * Read paths stay loose (a field a newer Kingdom adds is dropped, not fatal).
86
+ */
87
+ export const signetGrantSchema = z.discriminatedUnion('resourceModel', [
88
+ z
89
+ .object({ ...integrationResourceShape, kind: z.string().min(1).max(80) })
90
+ .superRefine(fullOrOperations),
91
+ z.object(projectGrantSchema.shape),
92
+ ]);
93
+ export type SignetGrant = z.infer<typeof signetGrantSchema>;
94
+
95
+ /**
96
+ * What a party asks for on one resource: `full` or picks, its own lens layer (Kingdom assigns the
97
+ * layer its id and chains it under the base, or under the request when an approver writes it), and
98
+ * terms.
99
+ */
100
+ const askedOperations = {
101
+ operations: operationPickSchema,
102
+ lens: lensLayerSchema.optional(),
103
+ terms: grantTermsSchema.optional(),
104
+ };
105
+ /** A resource asked for by id. */
106
+ export const signetResourceSchema = z.discriminatedUnion('resourceModel', [
107
+ z.strictObject({
108
+ resourceModel: z.literal('IntegrationResource'),
109
+ integrationResourceId: z.uuid(),
110
+ ...askedOperations,
111
+ }),
112
+ projectGrantSchema,
113
+ ]);
114
+ export type SignetResource = z.infer<typeof signetResourceSchema>;
115
+
116
+ /**
117
+ * A resource asked for by kind, and optionally name, when the asker does not know its id: an
118
+ * Installation registering, or a party asking to join a Foundry's `threads` (one per Foundry, so the
119
+ * integration id names it). Kingdom resolves it to the resource's id before anyone reviews it.
120
+ */
121
+ export const requestedResourceKindSchema = z.strictObject({
122
+ resourceKind: z.string().min(1).max(80),
123
+ resourceName: z.string().trim().min(1).max(120).optional(),
124
+ ...askedOperations,
125
+ });
126
+ export const requestedResourceSchema = z.union([signetResourceSchema, requestedResourceKindSchema]);
127
+ export type RequestedResource = z.input<typeof requestedResourceSchema>;
@@ -14,7 +14,7 @@ import {
14
14
  type ThreadRefusal,
15
15
  type ThreadRequestFrame,
16
16
  type ThreadSubscribeFrame,
17
- type ThreadsLens,
17
+ type ThreadsTerms,
18
18
  threadAppendFrameSchema,
19
19
  threadLeaseFrameSchema,
20
20
  threadRefused,
@@ -37,7 +37,7 @@ export type ThreadSubscription = {
37
37
  threadId: string;
38
38
  acceptedRevision: number;
39
39
  member: ThreadSubscribeFrame['member'];
40
- lens: ThreadsLens;
40
+ terms: ThreadsTerms;
41
41
  };
42
42
  /** `overflow`: too many entries arrived while the snapshot was taken; the subscribe is refused and retried. */
43
43
  export type ThreadSubscriptionEnd = 'unsubscribed' | 'lease' | 'disconnected' | 'overflow';
@@ -106,18 +106,6 @@ const maxHeldEntries = 1_000;
106
106
  /** WebSocket.OPEN, read from the spec rather than whichever WebSocket class was passed. */
107
107
  const OPEN = 1;
108
108
 
109
- /** An unknown selector form (or a `within`, which no v1 form fills) is refused as `unknown_selector`. */
110
- const unknownSelector = (frame: Record<string, unknown>) => {
111
- const threads = z
112
- .object({ threads: z.object({ form: z.string(), within: z.unknown().optional() }).loose() })
113
- .loose()
114
- .safeParse(frame.lens);
115
- return (
116
- threads.success &&
117
- (threads.data.threads.form !== 'ids' || threads.data.threads.within !== undefined)
118
- );
119
- };
120
-
121
109
  /**
122
110
  * Kingdom's live line to an Installation. It proves the installation's key, then receives a
123
111
  * snapshot of its inquiries and Signets on connect and again whenever one changes. While the
@@ -349,11 +337,7 @@ export class InstallationSocket {
349
337
  const frame = threadRequestFrameSchema.safeParse(raw);
350
338
  if (!frame.success) {
351
339
  this.options.onError?.(frame.error);
352
- return this.refuse(
353
- socket,
354
- requestId.data,
355
- unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
356
- );
340
+ return this.refuse(socket, requestId.data, 'invalid_request');
357
341
  }
358
342
  const host = this.options.threads;
359
343
  if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
@@ -401,11 +385,7 @@ export class InstallationSocket {
401
385
  const parsed = threadSubscribeFrameSchema.safeParse(raw);
402
386
  if (!parsed.success) {
403
387
  this.options.onError?.(parsed.error);
404
- return this.refuse(
405
- socket,
406
- requestId.data,
407
- unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
408
- );
388
+ return this.refuse(socket, requestId.data, 'invalid_request');
409
389
  }
410
390
  const host = this.options.threads;
411
391
  if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
@@ -416,7 +396,7 @@ export class InstallationSocket {
416
396
  threadId: frame.threadId,
417
397
  acceptedRevision: frame.acceptedRevision,
418
398
  member: frame.member,
419
- lens: frame.lens,
399
+ terms: frame.terms,
420
400
  leaseUntil: Date.now() + this.leaseMs(),
421
401
  sentSeq: null,
422
402
  seq: 0,
package/src/lens.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { LensNarrowing, PathProjection, StoredLens } from '@inixiative/json-rules';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * A grant's lens is a chain of json-rules layers over the integration's base lens for the resource's
6
+ * model (Aron, 2026-11-01: "root `where` filters; otherwise a narrowing of shape (pick/omit)… No
7
+ * lens means the default"). The base lives in Kingdom's catalog, as code, under the id
8
+ * `provider:kind`; a grant row stores only the layers under it, as json-rules `StoredLens` records,
9
+ * the request's first and an approver's after it. Every layer ANDs with the ones above it, so an
10
+ * approver can only narrow. This package carries the layers; Kingdom validates, composes
11
+ * (`composeLens`) and applies them (`projectRows`).
12
+ */
13
+
14
+ /** One layer as a party writes it: a json-rules narrowing without its parent. */
15
+ export type LensLayer = Omit<LensNarrowing, 'parent'>;
16
+ /** One stored layer: its id and the ids it composes under, the base first. Never a base itself. */
17
+ export type StoredLensLayer = Pick<StoredLens, 'id' | 'parents'> & LensLayer;
18
+
19
+ const narrowingPart = {
20
+ root: z.record(z.string(), z.unknown()).optional(),
21
+ mapDefaults: z.record(z.string(), z.unknown()).optional(),
22
+ };
23
+ const layerId = z.string().min(1).max(200);
24
+
25
+ /**
26
+ * A layer's envelope only: what is inside `root` and `mapDefaults` is json-rules', unchecked here.
27
+ * Kingdom runs `validateNarrowing` (or `composeLens`) on it before reading it as a `LensLayer`.
28
+ */
29
+ export const lensLayerSchema = z.strictObject(narrowingPart);
30
+ export const storedLensLayerSchema = z.strictObject({
31
+ id: layerId,
32
+ parents: z.array(layerId).min(1).max(20),
33
+ ...narrowingPart,
34
+ });
35
+ export const storedLensLayersSchema = z.array(storedLensLayerSchema).max(20);
36
+
37
+ /**
38
+ * A base lens as Kingdom's catalog carries it: the json-rules `Lens` itself (its field maps, map name
39
+ * and model) with an `id` and no parents. Only the envelope is checked here; a holder composes it
40
+ * under a grant's layers with json-rules `composeLens`, which validates what is inside.
41
+ */
42
+ export const storedBaseLensSchema = z.looseObject({
43
+ id: layerId,
44
+ parents: z.array(layerId).max(0),
45
+ mapName: z.string().min(1),
46
+ model: z.string().min(1),
47
+ maps: z.record(z.string(), z.unknown()),
48
+ });
49
+
50
+ /** The id of an integration's base lens for a resource kind's model, in Kingdom's catalog. */
51
+ export const baseLensId = (provider: string, kind: string) => `${provider}:${kind}`;
52
+
53
+ /**
54
+ * A grant's lens as the refusals read it: its own layers (empty = the base) and json-rules'
55
+ * `projectLens` of the composed chain, which Kingdom computes after validating the layers.
56
+ */
57
+ export type GrantLens = { layers: readonly LensLayer[]; projection: PathProjection };
58
+
59
+ /** Whether the composed lens filters rows anywhere: a `where` on the anchor or along a relation. */
60
+ export const projectionFilters = (projection: PathProjection) =>
61
+ Object.values(projection).some((visit) => visit.whereClauses.length > 0);
62
+
63
+ /** Whether the composed lens shows `field` of its anchor model (the visit keyed by the model alone). */
64
+ export const projectionShows = (projection: PathProjection, field: string) =>
65
+ Object.entries(projection).some(
66
+ ([path, visit]) => !path.includes('.') && Object.hasOwn(visit.fields, field),
67
+ );
@@ -0,0 +1,144 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Operation picks (Aron, 2026-11-01: "any model whole, or whatever group of operations whole, or
5
+ * to any degree of specificity"). A provider's catalog is a flat list of
6
+ * dotted leaf keys per resource kind (`commits.read`, `events.pull_request.review_requested`); the
7
+ * nesting is carried by the dots and derived, never stored. A grant asks for `full` (everything the
8
+ * resource has now and later, capped live by the integration's baseline) or picks at any node: a
9
+ * leaf, or a prefix ending `.*` that takes every leaf under it. Picks resolve to leaves once, when
10
+ * the grant is saved, so a later catalog addition reaches only `full` rows.
11
+ */
12
+ const segment = '[A-Za-z0-9_-]+';
13
+ export const operationKeySchema = z
14
+ .string()
15
+ .max(80)
16
+ .regex(new RegExp(`^${segment}(?:\\.${segment})*$`));
17
+ /** A leaf key, or a prefix ending `.*`. */
18
+ export const operationPickKeySchema = z
19
+ .string()
20
+ .max(82)
21
+ .regex(new RegExp(`^${segment}(?:\\.${segment})*(?:\\.\\*)?$`));
22
+
23
+ export const operationPickSchema = z.union([
24
+ z.strictObject({ full: z.literal(true) }),
25
+ z.strictObject({ picks: z.array(operationPickKeySchema).min(1).max(100) }),
26
+ ]);
27
+ export type OperationPick = z.infer<typeof operationPickSchema>;
28
+
29
+ /** Resolved leaves, as a grant row stores them. Picks of nested events may run past a hundred. */
30
+ export const resolvedOperationsSchema = z
31
+ .array(operationKeySchema)
32
+ .min(1)
33
+ .max(1000)
34
+ .refine((operations) => new Set(operations).size === operations.length, {
35
+ message: 'Each operation is listed once',
36
+ });
37
+
38
+ /**
39
+ * What a row reaches: `full`, or the leaves its picks resolved to. Not the same axis as
40
+ * `Integration.full` / `Token.full`, which make a delegate act with its owner's whole authority.
41
+ */
42
+ export type OperationScope = { full: true } | { full: false; operations: string[] };
43
+
44
+ export class OperationPickError extends Error {
45
+ readonly code = 'unknown_operation_pick';
46
+ constructor(readonly pick: string) {
47
+ super(`No operation matches the pick '${pick}'`);
48
+ this.name = 'OperationPickError';
49
+ }
50
+ }
51
+
52
+ const prefixOf = (pick: string) => (pick.endsWith('.*') ? pick.slice(0, -1) : null);
53
+
54
+ /**
55
+ * The leaves `picks` reach among `leaves` (a resource kind's catalog keys), in catalog order. A
56
+ * pick that reaches nothing throws `OperationPickError`, so a typo never saves an empty grant.
57
+ */
58
+ export const resolveOperationPicks = (
59
+ leaves: readonly string[],
60
+ pick: OperationPick,
61
+ ): OperationScope => {
62
+ if ('full' in pick) return { full: true };
63
+ const chosen = new Set<string>();
64
+ for (const key of pick.picks) {
65
+ const prefix = prefixOf(key);
66
+ const reached = prefix
67
+ ? leaves.filter((leaf) => leaf.startsWith(prefix))
68
+ : leaves.filter((leaf) => leaf === key);
69
+ if (reached.length === 0) throw new OperationPickError(key);
70
+ for (const leaf of reached) chosen.add(leaf);
71
+ }
72
+ return { full: false, operations: leaves.filter((leaf) => chosen.has(leaf)) };
73
+ };
74
+
75
+ /**
76
+ * One node of a resource kind's operation tree. `key` is its dotted path, `pick` the pick that
77
+ * takes it (the key for a leaf, `key.*` for a group), and a key that is both a leaf and a prefix is
78
+ * a leaf with children (its own pick then takes only the leaf).
79
+ */
80
+ export type OperationNode = {
81
+ key: string;
82
+ segment: string;
83
+ leaf: boolean;
84
+ pick: string;
85
+ /** `key.*`, whenever the node has children (a leaf that is also a prefix included). */
86
+ groupPick?: string;
87
+ children: OperationNode[];
88
+ };
89
+
90
+ /** The tree the dotted keys describe, in catalog order: what a grant UI offers to pick from. */
91
+ export const operationTree = (leaves: readonly string[]): OperationNode[] => {
92
+ const roots: OperationNode[] = [];
93
+ for (const leaf of leaves) {
94
+ let level = roots;
95
+ const parts = leaf.split('.');
96
+ parts.forEach((part, depth) => {
97
+ const key = parts.slice(0, depth + 1).join('.');
98
+ let node = level.find((candidate) => candidate.key === key);
99
+ if (!node) {
100
+ node = { key, segment: part, leaf: false, pick: `${key}.*`, children: [] };
101
+ level.push(node);
102
+ }
103
+ if (depth < parts.length - 1) node.groupPick = `${key}.*`;
104
+ if (depth === parts.length - 1) {
105
+ node.leaf = true;
106
+ node.pick = key;
107
+ }
108
+ level = node.children;
109
+ });
110
+ }
111
+ return roots;
112
+ };
113
+
114
+ /**
115
+ * `inner` reaches no operation `outer` doesn't. A full `outer` contains everything; a full `inner`
116
+ * under an `outer` that is not full is never a narrowing, even when `outer` lists every leaf today,
117
+ * because `full` keeps growing with the catalog.
118
+ */
119
+ export const narrowsOperations = (inner: OperationScope, outer: OperationScope): boolean => {
120
+ if (outer.full) return true;
121
+ if (inner.full) return false;
122
+ return inner.operations.every((operation) => permitsOperation(outer, operation));
123
+ };
124
+
125
+ /** Whether `scope` reaches `operation`. */
126
+ export const permitsOperation = (scope: OperationScope, operation: string) =>
127
+ scope.full || scope.operations.includes(operation);
128
+
129
+ /**
130
+ * The first refusal among the catalog operations `scope` reaches, or null. A full scope is checked
131
+ * against the whole catalog, so run it again whenever the catalog grows.
132
+ */
133
+ export const scopeRefusal = <O extends string>(
134
+ scope: OperationScope,
135
+ catalog: readonly O[],
136
+ refusal: (operation: O) => string | null,
137
+ ) => {
138
+ for (const operation of catalog) {
139
+ if (!permitsOperation(scope, operation)) continue;
140
+ const reason = refusal(operation);
141
+ if (reason) return reason;
142
+ }
143
+ return null;
144
+ };
package/src/threads.ts CHANGED
@@ -16,32 +16,6 @@ export const threadsResourceKind = 'threads';
16
16
  export const foundryIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/);
17
17
  export const threadIdSchema = foundryIdSchema;
18
18
 
19
- /**
20
- * Selector forms that are named for standing Signets (design §9) but not built. Each fails to parse
21
- * on every party until a release adds it to `threadSelectorSchema`, so a new form only takes effect
22
- * once every side has upgraded and never widens access silently.
23
- *
24
- * - `{ form: 'withMember' }`: threads opened with the grant's recipient;
25
- * - `{ form: 'tag', tag }`: threads carrying a tag.
26
- */
27
- export const unbuiltThreadSelectorForms = ['withMember', 'tag'] as const;
28
-
29
- /**
30
- * `ids` takes `within` only once a non-`ids` form exists: `within` will hold exactly one such form
31
- * (never a selector, so it can't nest), set only by approving a request for a selector form narrowed
32
- * to `ids` (the grant is then `ids ∩ within`), and approval may never drop or widen it. Until then
33
- * the strict object refuses `within` like any unknown key: structural, and representable in Kingdom's
34
- * OpenAPI (a `z.never()` placeholder is not).
35
- */
36
- export const idsThreadSelectorSchema = z.strictObject({
37
- form: z.literal('ids'),
38
- ids: z.array(threadIdSchema).min(1).max(100),
39
- });
40
-
41
- /** Which threads a grant reaches. A discriminated union on `form`; v1 builds only `ids`. */
42
- export const threadSelectorSchema = z.discriminatedUnion('form', [idsThreadSelectorSchema]);
43
- export type ThreadSelector = z.infer<typeof threadSelectorSchema>;
44
-
45
19
  /** `own`: the member's posts and the sends addressed to it. `thread`: the main thread's messages. */
46
20
  export const threadViewSchema = z.enum(['own', 'thread']);
47
21
  /** `note`: rides the owner's next turn. `turn`: queued as a turn of its own. */
@@ -57,38 +31,20 @@ export const threadOutboundSchema = z.enum(['review', 'auto']);
57
31
  export const threadFloorTermsSchema = z.strictObject({ mayTake: z.boolean().default(false) });
58
32
 
59
33
  /**
60
- * A member's terms on a `threads` resource. Every term but the selector defaults to its narrowest
61
- * value, so a lens that omits one can only be more careful. `threads` has no default: `{}` is never a
62
- * valid threads lens, which is why a grant's preset must fill it (review R9).
34
+ * A member's terms on a `threads` resource: how it takes part, not which threads it reaches (that
35
+ * is the grant's lens, a json-rules filter Kingdom applies at admission). Every term defaults to its
36
+ * narrowest value, so terms that omit one can only be more careful.
63
37
  */
64
- export const threadsLensSchema = z.strictObject({
65
- threads: threadSelectorSchema,
38
+ export const threadsTermsSchema = z.strictObject({
66
39
  view: threadViewSchema.default('own'),
67
40
  delivery: threadDeliverySchema.default('note'),
68
41
  outbound: threadOutboundSchema.default('review'),
69
42
  floor: threadFloorTermsSchema.default({ mayTake: false }),
70
43
  });
71
- export type ThreadsLens = z.infer<typeof threadsLensSchema>;
72
-
73
- /** Is `threadId` inside the selector? For `ids`, Kingdom answers this at admission. */
74
- export const selectsThread = (selector: ThreadSelector, threadId: string): boolean => {
75
- switch (selector.form) {
76
- case 'ids':
77
- return selector.ids.includes(threadId);
78
- }
79
- };
44
+ export type ThreadsTerms = z.infer<typeof threadsTermsSchema>;
80
45
 
81
- /** `granted` reaches no thread `requested` doesn't: per form, as the design's narrowing rules say. */
82
- export const narrowsThreadSelector = (
83
- granted: ThreadSelector,
84
- requested: ThreadSelector,
85
- ): boolean => {
86
- switch (requested.form) {
87
- case 'ids':
88
- // When `within` exists, it must also be kept as asked: never dropped, never added.
89
- return granted.form === 'ids' && granted.ids.every((id) => requested.ids.includes(id));
90
- }
91
- };
46
+ /** The row a threads grant's `where` is evaluated over at admission: the thread, by `id`. */
47
+ export const threadRow = (threadId: string) => ({ id: threadIdSchema.parse(threadId) });
92
48
 
93
49
  const order = <T extends string>(values: readonly T[]) => {
94
50
  const rank = (value: T) => values.indexOf(value);
@@ -100,10 +56,10 @@ const narrowsOutbound = order(threadOutboundSchema.options);
100
56
 
101
57
  /**
102
58
  * Approving a request may only narrow (`own` < `thread`, `note` < `turn`, `review` < `auto`, and
103
- * `floor.mayTake` only where it was asked for).
59
+ * `floor.mayTake` only where it was asked for). Terms don't compose like lens layers, so approval
60
+ * checks them here.
104
61
  */
105
- export const narrowsThreadsLens = (granted: ThreadsLens, requested: ThreadsLens): boolean =>
106
- narrowsThreadSelector(granted.threads, requested.threads) &&
62
+ export const narrowsThreadsTerms = (granted: ThreadsTerms, requested: ThreadsTerms): boolean =>
107
63
  narrowsView(granted.view, requested.view) &&
108
64
  narrowsDelivery(granted.delivery, requested.delivery) &&
109
65
  narrowsOutbound(granted.outbound, requested.outbound) &&
@@ -130,7 +86,7 @@ export const threadOperationNames: Record<ThreadOperation, string> = {
130
86
  * `local` is a member on the host's own machine (its own Codex, a local Herald), joined in-process
131
87
  * with no Signet; `signet` is one that reaches the host through Kingdom.
132
88
  * Read paths (entries, views) use plain objects, so a host that adds a field never costs an older
133
- * member its entries; lenses and request frames stay strict.
89
+ * member its entries; terms and request frames stay strict.
134
90
  */
135
91
  export const conversationMemberSchema = z.object({
136
92
  kind: z.enum(['owner', 'local', 'signet']),
@@ -263,14 +219,13 @@ export type ThreadResult<O extends keyof typeof threadResultSchemas> = z.infer<
263
219
  >;
264
220
 
265
221
  /**
266
- * Why the host refused. The first six are the design's refusals, which Kingdom records as
222
+ * Why the host refused. The first five are the design's refusals, which Kingdom records as
267
223
  * `AccessExecution.status = 'denied'` with this `errorCode` (§5.4). `invalid_request` is a frame the
268
224
  * host could not read for any other reason, and `failed` an error inside the host; Kingdom records
269
225
  * both as `failed`.
270
226
  */
271
227
  export const threadRefusalSchema = z.enum([
272
228
  'unknown_thread',
273
- 'unknown_selector',
274
229
  'thread_closed',
275
230
  'floor_denied',
276
231
  'not_invited',
@@ -281,7 +236,6 @@ export const threadRefusalSchema = z.enum([
281
236
  export type ThreadRefusal = z.infer<typeof threadRefusalSchema>;
282
237
  export const threadDeniedRefusals = [
283
238
  'unknown_thread',
284
- 'unknown_selector',
285
239
  'thread_closed',
286
240
  'floor_denied',
287
241
  'not_invited',
@@ -298,10 +252,11 @@ const relayed = {
298
252
  /** The `AccessExecution` id: one per admitted call, and the id the answer carries back. */
299
253
  requestId: z.uuid(),
300
254
  signetId: z.uuid(),
301
- /** The Signet revision Kingdom admitted the call under; the lens is that revision's. */
255
+ /** The Signet revision Kingdom admitted the call under; the terms are that revision's. */
302
256
  acceptedRevision: z.number().int().min(1),
303
257
  member: threadMemberSchema,
304
- lens: threadsLensSchema,
258
+ /** The member's terms, as Kingdom admitted the call under them. */
259
+ terms: threadsTermsSchema,
305
260
  };
306
261
  const requestFrame = <O extends ThreadOperation>(operation: O) =>
307
262
  z.strictObject({
@@ -453,7 +408,7 @@ export const accessThreadCloseReasonSchema = z.enum([
453
408
  /** The member closed it. */
454
409
  'left',
455
410
  'revoked',
456
- /** Any revision closes the stream; the member reopens under the new lens. */
411
+ /** Any revision closes the stream; the member reopens under the new revision. */
457
412
  'revised',
458
413
  'enrollmentRevoked',
459
414
  'expired',