@phosphorco/bb-identity 0.2.1 → 0.3.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.
package/README.md CHANGED
@@ -463,3 +463,17 @@ lookup decoding. Later conformance must use a packed artifact with two
463
463
  independently bundled UI consumers and real upstream→fork→upstream→fork
464
464
  sessions. Preserve native content, provider references and upstream grouped-edit
465
465
  restrictions in that proof.
466
+
467
+ ## External person attribution V1 (0.3)
468
+
469
+ `binding.server.externalContributionCapabilities()` synchronously distinguishes an absent capability (`ok(null)`) from a malformed or incompatible host (`ok: false`). A capable host advertises V1, canonical `person` identities, accepted/queued history, and `rollback: 'requires-capable-host'`. This optional subprotocol fits inside outer protocol 1; legacy `sendExternal` and `lookupOperation` retain their existing wire format and hashes.
470
+
471
+ For a new operation, `sendExternalContribution({version: 1, author, personAttribution}, input)` retains the external Slack/integration origin and an optional canonical person snapshot. The person snapshot contains `identity`, `presentation`, and `evidence: 'integration-asserted'`. It attributes a contribution in a trusted workspace; it conveys no authentication or authorization. Local `default-user` identities are not canonical people.
472
+
473
+ Persist the exact contribution, operation ID, prompt bytes, and selected transport before dispatch. Capability absence may select legacy external-only attribution for a **new** operation. Never upgrade, downgrade, or rebuild an existing operation from a current directory or mapping. A malformed capability is an incompatibility, not permission to fall back.
474
+
475
+ V1 receipts confirm the frozen `externalContribution`. Reconcile V1 exclusively through `lookupExternalContribution`, checking its returned attribution against the reservation. Wrong-operation receipts fail validation. Changed or malformed send receipts and exceptions after dispatch are indeterminate. Unknown/unsupported lookup does not authorize resend; only `absent-final` permits the same immutable operation to be retried. Legacy final receipts remain unchanged.
476
+
477
+ The default host rendering policy adds the integration sender envelope once. A producer such as Rosetta must select `externalMessageRendering: 'producer'` at binding construction; its prompt bytes remain unchanged. A capable native host projects the person into the native timeline while contribution/attempt history keeps the external origin and optional `personAttribution`. This does not add native-message history queries.
478
+
479
+ Native history containing integration-asserted person authors requires a capable runtime even after queues drain or plugins are downgraded. Do not roll back to a host that only accepts provider-verified person authors. Package-only rollback retains the legacy external path; it does not make old native readers understand newer stored authorship.
package/bb.d.ts CHANGED
@@ -76,6 +76,9 @@ export interface BbInvocationServer {
76
76
  selfProfile(invocation: BbInvocation): Promise<Result<import('./model.js').IdentityProfile>>;
77
77
  personRequest(invocation: BbInvocation): Promise<Result<PersonRequest<BbPromptInput>>>;
78
78
  readonly sendExternal: IdentityServer<unknown, unknown, BbPromptInput>['sendExternal'];
79
+ readonly externalContributionCapabilities: IdentityServer<unknown, unknown, BbPromptInput>['externalContributionCapabilities'];
80
+ readonly sendExternalContribution: IdentityServer<unknown, unknown, BbPromptInput>['sendExternalContribution'];
81
+ readonly lookupExternalContribution: IdentityServer<unknown, unknown, BbPromptInput>['lookupExternalContribution'];
79
82
  readonly lookupOperation: IdentityServer<unknown, unknown, BbPromptInput>['lookupOperation'];
80
83
  /** Bounded, validated provenance reads; disposal fences new raw history calls. */
81
84
  readonly history: IdentityServer<unknown, unknown, BbPromptInput>['history'];
@@ -172,7 +172,7 @@ function isOrigin(value) {
172
172
  return value.kind === "unknown" && (value.reason === "legacy" || value.reason === "upstream-unattributed" || value.reason === "missing-source");
173
173
  }
174
174
  function isContribution(value) {
175
- return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
175
+ return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
176
176
  }
177
177
  function isCorrelation(value) {
178
178
  return isRecord(value) && idCodec("thread").decode(value.threadId).ok && isBoundedString(value.turnId) && isBoundedString(value.attemptId) && (value.toolCallId === null || isBoundedString(value.toolCallId));
@@ -249,6 +249,17 @@ var provenanceCodec = {
249
249
  return value;
250
250
  }
251
251
  };
252
+ var externalPersonSnapshotCodec = {
253
+ decode(value) {
254
+ if (!isRecord(value) || Object.keys(value).some((key) => !["identity", "presentation", "evidence"].includes(key)) || value.evidence !== "integration-asserted" || !isActorSnapshot(value) || !isRecord(value.identity) || value.identity.kind !== "person" || Object.keys(value.identity).some((key) => !["kind", "key", "issuer", "subject"].includes(key))) {
255
+ return err("invalid-input", "External person attribution requires a canonical person and integration-asserted evidence.");
256
+ }
257
+ return ok(value);
258
+ },
259
+ encode(value) {
260
+ return value;
261
+ }
262
+ };
252
263
  function defaultIdentityKey(instance) {
253
264
  return `local:${instance}`;
254
265
  }
@@ -766,7 +777,7 @@ function isContribution2(value) {
766
777
  if (!isRecord3(origin))
767
778
  return false;
768
779
  const validOrigin = origin.kind === "person" ? snapshot(origin.actor) && isRecord3(origin.actor) && isPersonIdentity(origin.actor.identity) : origin.kind === "machine" ? snapshot(origin.actor) && isRecord3(origin.actor) && isMachineIdentity(origin.actor.identity) : origin.kind === "external" ? snapshot(origin.actor) && isRecord3(origin.actor) && isExternalIdentity(origin.actor.identity) : origin.kind === "agent" ? origin.agentId === null || idCodec("agent").decode(origin.agentId).ok : origin.kind === "system" ? typeof origin.reason === "string" && origin.reason.length > 0 && origin.reason.length <= 1024 : origin.kind === "unknown" && (origin.reason === "legacy" || origin.reason === "upstream-unattributed" || origin.reason === "missing-source");
769
- return validOrigin && (value.latestEditor === null || snapshot(value.latestEditor));
780
+ return (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && validOrigin && (value.latestEditor === null || snapshot(value.latestEditor));
770
781
  }
771
782
  function decodeEvidencePage(value, decodeItem) {
772
783
  if (!isRecord3(value) || typeof value.status !== "string")
@@ -1101,6 +1112,7 @@ function createHostAdapter(options) {
1101
1112
  });
1102
1113
  const contexts = new WeakMap;
1103
1114
  return {
1115
+ ...externalContributionMethods(upstream, extension, () => disposed),
1104
1116
  pluginId: upstream.pluginId,
1105
1117
  instanceId: upstream.instanceId,
1106
1118
  session: (context) => upstream.session(context),
@@ -1146,6 +1158,7 @@ function createHostAdapter(options) {
1146
1158
  const error = protocolError(extension);
1147
1159
  const unavailableResult = async () => ({ ok: false, error });
1148
1160
  return {
1161
+ ...externalContributionMethods(upstream, extension, () => disposed),
1149
1162
  pluginId: upstream.pluginId,
1150
1163
  instanceId: upstream.instanceId,
1151
1164
  session: async () => ({ status: error.code === "incompatible" ? "incompatible" : "unavailable", instanceId: upstream.instanceId, error }),
@@ -1221,6 +1234,7 @@ function createHostAdapter(options) {
1221
1234
  });
1222
1235
  const scopes = new WeakMap;
1223
1236
  return {
1237
+ ...externalContributionMethods(upstream, extension, () => disposed),
1224
1238
  pluginId: upstream.pluginId,
1225
1239
  instanceId: protocol.instanceId,
1226
1240
  async session(context) {
@@ -1307,6 +1321,123 @@ function createHostAdapter(options) {
1307
1321
  }
1308
1322
  };
1309
1323
  }
1324
+ function decodeExternalContribution(value, strict = true) {
1325
+ if (!isRecord3(value) || value.version !== 1 || !isRecord3(value.author) || strict && Object.keys(value).some((key) => !["version", "author", "personAttribution"].includes(key)) || strict && Object.keys(value.author).some((key) => !["subject", "presentation"].includes(key)) || strict && isRecord3(value.author.presentation) && Object.keys(value.author.presentation).some((key) => !["displayName", "handle", "avatarUrl"].includes(key)) || !idCodec("subject").decode(value.author.subject).ok || !isProfilePresentation(value.author.presentation)) {
1326
+ return err("invalid-input", "Invalid V1 external contribution.");
1327
+ }
1328
+ let person = value.personAttribution;
1329
+ if (!strict && isRecord3(person) && isRecord3(person.identity))
1330
+ person = {
1331
+ identity: { kind: person.identity.kind, key: person.identity.key, issuer: person.identity.issuer, subject: person.identity.subject },
1332
+ presentation: isRecord3(person.presentation) ? { displayName: person.presentation.displayName, handle: person.presentation.handle, avatarUrl: person.presentation.avatarUrl } : person.presentation,
1333
+ evidence: person.evidence
1334
+ };
1335
+ if (strict && isRecord3(person) && isRecord3(person.presentation) && Object.keys(person.presentation).some((key) => !["displayName", "handle", "avatarUrl"].includes(key)))
1336
+ return err("invalid-input", "Unexpected presentation field.");
1337
+ const decoded = person === undefined ? null : externalPersonSnapshotCodec.decode(person);
1338
+ if (decoded && !decoded.ok)
1339
+ return decoded;
1340
+ return ok({
1341
+ version: 1,
1342
+ author: { subject: value.author.subject, presentation: { displayName: value.author.presentation.displayName, handle: value.author.presentation.handle, avatarUrl: value.author.presentation.avatarUrl } },
1343
+ ...decoded?.ok ? { personAttribution: decoded.value } : {}
1344
+ });
1345
+ }
1346
+ var externalContributionV1Codec = {
1347
+ decode: (value) => decodeExternalContribution(value),
1348
+ encode: (value) => value
1349
+ };
1350
+ function canonicalExternal(value) {
1351
+ if (Array.isArray(value))
1352
+ return `[${value.map(canonicalExternal).join(",")}]`;
1353
+ if (isRecord3(value))
1354
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalExternal(value[key])}`).join(",")}}`;
1355
+ return JSON.stringify(value);
1356
+ }
1357
+ function externalContributionMethods(upstream, extension, disposed) {
1358
+ const support = () => {
1359
+ if (disposed())
1360
+ return err("disposed", "Identity host is disposed.");
1361
+ if (extension.status === "absent")
1362
+ return ok(null);
1363
+ if (extension.status !== "supported")
1364
+ return { ok: false, error: protocolError(extension) };
1365
+ const value = extension.protocol.externalContributions;
1366
+ if (value === undefined)
1367
+ return ok(null);
1368
+ if (!isRecord3(value) || !Array.isArray(value.versions) || !value.versions.length || !value.versions.every((version) => Number.isInteger(version) && version > 0))
1369
+ return err("incompatible", "Malformed external contribution capability.");
1370
+ if (!value.versions.includes(1))
1371
+ return ok(null);
1372
+ if (!Array.isArray(value.personKinds) || value.personKinds.length !== 1 || value.personKinds[0] !== "person" || value.history !== "accepted-and-queued" || value.rollback !== "requires-capable-host" || typeof value.acceptV1 !== "function" || typeof value.lookupV1 !== "function")
1373
+ return err("incompatible", "Malformed V1 external contribution capability.");
1374
+ return ok(value);
1375
+ };
1376
+ const outcome = (raw) => {
1377
+ const decoded = decodeAcceptance(raw);
1378
+ if (!decoded.ok || decoded.value.status !== "submitted")
1379
+ return decoded;
1380
+ if (decoded.value.receipt.evidence !== "host-accepted")
1381
+ return err("incompatible", "V1 needs durable native acceptance.");
1382
+ const receipt = decoded.value.receipt;
1383
+ const contribution = decodeExternalContribution(receipt.externalContribution, false);
1384
+ return contribution.ok ? ok({ status: "submitted", receipt: { ...decoded.value.receipt, externalContribution: contribution.value } }) : err("incompatible", "Host receipt omitted or changed V1 attribution.");
1385
+ };
1386
+ return {
1387
+ externalContributionCapabilities() {
1388
+ const negotiated = support();
1389
+ return !negotiated.ok ? negotiated : ok(negotiated.value ? { version: 1, personKinds: ["person"], history: "accepted-and-queued", rollback: "requires-capable-host" } : null);
1390
+ },
1391
+ async sendExternalContribution(contribution, input, options) {
1392
+ const operationId = input.operationId;
1393
+ const decoded = decodeExternalContribution(contribution);
1394
+ if (!decoded.ok)
1395
+ return { status: "rejected", error: decoded.error };
1396
+ const negotiated = support();
1397
+ if (!negotiated.ok)
1398
+ return { status: "rejected", error: negotiated.error };
1399
+ if (!negotiated.value)
1400
+ return { status: "rejected", error: { code: "unsupported", message: "Native external-person attribution V1 is unavailable.", retry: "never" } };
1401
+ if (options?.signal?.aborted)
1402
+ return { status: "rejected", error: { code: "expired", message: "The send was cancelled before dispatch.", retry: "never" } };
1403
+ const frozen = JSON.parse(JSON.stringify(decoded.value));
1404
+ const wire = JSON.parse(JSON.stringify(wireInput(input, upstream.inputCodec)));
1405
+ try {
1406
+ const result = outcome(await negotiated.value.acceptV1({ contribution: frozen, input: wire }, options));
1407
+ if (!result.ok)
1408
+ return { status: "indeterminate", operationId, message: result.error.message };
1409
+ if (result.value.status === "submitted" && (result.value.receipt.operationId !== operationId || canonicalExternal(result.value.receipt.externalContribution) !== canonicalExternal(frozen))) {
1410
+ return { status: "indeterminate", operationId, message: "Host receipt did not confirm the reserved attribution." };
1411
+ }
1412
+ return result.value;
1413
+ } catch {
1414
+ return { status: "indeterminate", operationId, message: "External contribution dispatch outcome is unknown; reconcile this operation." };
1415
+ }
1416
+ },
1417
+ async lookupExternalContribution(operationId, options) {
1418
+ const negotiated = support();
1419
+ if (!negotiated.ok)
1420
+ return negotiated;
1421
+ if (!negotiated.value)
1422
+ return ok({ status: "unknown", reason: "unsupported" });
1423
+ try {
1424
+ const result = asResult(await negotiated.value.lookupV1(operationId, options));
1425
+ if (!result.ok)
1426
+ return result;
1427
+ if (isRecord3(result.value) && result.value.status === "final") {
1428
+ const decoded = outcome(result.value.outcome);
1429
+ if (decoded.ok && decoded.value.status === "submitted" && decoded.value.receipt.operationId !== operationId) {
1430
+ return err("incompatible", "Host lookup returned a receipt for another operation.");
1431
+ }
1432
+ return decoded.ok ? ok({ status: "final", outcome: decoded.value }) : decoded;
1433
+ }
1434
+ return decodeLookup(result.value);
1435
+ } catch {
1436
+ return err("unavailable", "External contribution lookup is unavailable.", "after-reconnect");
1437
+ }
1438
+ }
1439
+ };
1440
+ }
1310
1441
  function inspectForkExtension(value) {
1311
1442
  if (value === undefined)
1312
1443
  return { status: "absent" };
@@ -1464,6 +1595,9 @@ function createIdentityServer(options) {
1464
1595
  },
1465
1596
  toolProvenance: (context) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.readToolProvenance(context),
1466
1597
  sendExternal: (author, input, readOptions) => disposed ? Promise.resolve({ status: "rejected", error: { code: "disposed", message: "Identity server is disposed.", retry: "never" } }) : host.sendExternal(author, input, readOptions),
1598
+ externalContributionCapabilities: () => disposed ? err("disposed", "Identity server is disposed.") : host.externalContributionCapabilities(),
1599
+ sendExternalContribution: (contribution, input, readOptions) => disposed ? Promise.resolve({ status: "rejected", error: { code: "disposed", message: "Identity server is disposed.", retry: "never" } }) : host.sendExternalContribution(contribution, input, readOptions),
1600
+ lookupExternalContribution: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.lookupExternalContribution(operationId, readOptions),
1467
1601
  lookupOperation: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.lookupOperation(operationId, readOptions),
1468
1602
  dispose
1469
1603
  };
@@ -2345,6 +2479,17 @@ function createBbIdentityServerBinding(options) {
2345
2479
  const renderedInput = upstream.value.externalMessageRendering === "host" ? renderExternalPrompt(upstream.value.driver.pluginId, author, input.input) : input.input;
2346
2480
  return server.sendExternal(author, { ...input, input: renderedInput }, readOptions);
2347
2481
  },
2482
+ externalContributionCapabilities: () => disposed ? err("disposed", "Identity binding is disposed.") : server.externalContributionCapabilities(),
2483
+ async sendExternalContribution(contribution, input, readOptions) {
2484
+ if (disposed)
2485
+ return { status: "rejected", error: { code: "disposed", message: "Identity binding is disposed.", retry: "never" } };
2486
+ const decoded = externalContributionV1Codec.decode(contribution);
2487
+ if (!decoded.ok)
2488
+ return { status: "rejected", error: decoded.error };
2489
+ const renderedInput = upstream.value.externalMessageRendering === "host" ? renderExternalPrompt(upstream.value.driver.pluginId, decoded.value.author, input.input) : input.input;
2490
+ return server.sendExternalContribution(decoded.value, { ...input, input: renderedInput }, readOptions);
2491
+ },
2492
+ lookupExternalContribution: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity binding is disposed.")) : server.lookupExternalContribution(operationId, readOptions),
2348
2493
  lookupOperation: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity binding is disposed.")) : server.lookupOperation(operationId, readOptions),
2349
2494
  history: guardedHistory
2350
2495
  };
@@ -107,7 +107,7 @@ function isOrigin(value) {
107
107
  return value.kind === "unknown" && (value.reason === "legacy" || value.reason === "upstream-unattributed" || value.reason === "missing-source");
108
108
  }
109
109
  function isContribution(value) {
110
- return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
110
+ return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
111
111
  }
112
112
  function isCorrelation(value) {
113
113
  return isRecord(value) && idCodec("thread").decode(value.threadId).ok && isBoundedString(value.turnId) && isBoundedString(value.attemptId) && (value.toolCallId === null || isBoundedString(value.toolCallId));
@@ -184,6 +184,17 @@ var provenanceCodec = {
184
184
  return value;
185
185
  }
186
186
  };
187
+ var externalPersonSnapshotCodec = {
188
+ decode(value) {
189
+ if (!isRecord(value) || Object.keys(value).some((key) => !["identity", "presentation", "evidence"].includes(key)) || value.evidence !== "integration-asserted" || !isActorSnapshot(value) || !isRecord(value.identity) || value.identity.kind !== "person" || Object.keys(value.identity).some((key) => !["kind", "key", "issuer", "subject"].includes(key))) {
190
+ return err("invalid-input", "External person attribution requires a canonical person and integration-asserted evidence.");
191
+ }
192
+ return ok(value);
193
+ },
194
+ encode(value) {
195
+ return value;
196
+ }
197
+ };
187
198
 
188
199
  // host-runtime.ts
189
200
  function isRecord2(value) {
@@ -375,7 +386,7 @@ function isContribution2(value) {
375
386
  if (!isRecord2(origin))
376
387
  return false;
377
388
  const validOrigin = origin.kind === "person" ? snapshot(origin.actor) && isRecord2(origin.actor) && isPersonIdentity(origin.actor.identity) : origin.kind === "machine" ? snapshot(origin.actor) && isRecord2(origin.actor) && isMachineIdentity(origin.actor.identity) : origin.kind === "external" ? snapshot(origin.actor) && isRecord2(origin.actor) && isExternalIdentity(origin.actor.identity) : origin.kind === "agent" ? origin.agentId === null || idCodec("agent").decode(origin.agentId).ok : origin.kind === "system" ? typeof origin.reason === "string" && origin.reason.length > 0 && origin.reason.length <= 1024 : origin.kind === "unknown" && (origin.reason === "legacy" || origin.reason === "upstream-unattributed" || origin.reason === "missing-source");
378
- return validOrigin && (value.latestEditor === null || snapshot(value.latestEditor));
389
+ return (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && validOrigin && (value.latestEditor === null || snapshot(value.latestEditor));
379
390
  }
380
391
  function decodeEvidencePage(value, decodeItem) {
381
392
  if (!isRecord2(value) || typeof value.status !== "string")
@@ -710,6 +721,7 @@ function createHostAdapter(options) {
710
721
  });
711
722
  const contexts = new WeakMap;
712
723
  return {
724
+ ...externalContributionMethods(upstream, extension, () => disposed),
713
725
  pluginId: upstream.pluginId,
714
726
  instanceId: upstream.instanceId,
715
727
  session: (context) => upstream.session(context),
@@ -755,6 +767,7 @@ function createHostAdapter(options) {
755
767
  const error = protocolError(extension);
756
768
  const unavailableResult = async () => ({ ok: false, error });
757
769
  return {
770
+ ...externalContributionMethods(upstream, extension, () => disposed),
758
771
  pluginId: upstream.pluginId,
759
772
  instanceId: upstream.instanceId,
760
773
  session: async () => ({ status: error.code === "incompatible" ? "incompatible" : "unavailable", instanceId: upstream.instanceId, error }),
@@ -830,6 +843,7 @@ function createHostAdapter(options) {
830
843
  });
831
844
  const scopes = new WeakMap;
832
845
  return {
846
+ ...externalContributionMethods(upstream, extension, () => disposed),
833
847
  pluginId: upstream.pluginId,
834
848
  instanceId: protocol.instanceId,
835
849
  async session(context) {
@@ -916,6 +930,123 @@ function createHostAdapter(options) {
916
930
  }
917
931
  };
918
932
  }
933
+ function decodeExternalContribution(value, strict = true) {
934
+ if (!isRecord2(value) || value.version !== 1 || !isRecord2(value.author) || strict && Object.keys(value).some((key) => !["version", "author", "personAttribution"].includes(key)) || strict && Object.keys(value.author).some((key) => !["subject", "presentation"].includes(key)) || strict && isRecord2(value.author.presentation) && Object.keys(value.author.presentation).some((key) => !["displayName", "handle", "avatarUrl"].includes(key)) || !idCodec("subject").decode(value.author.subject).ok || !isProfilePresentation(value.author.presentation)) {
935
+ return err("invalid-input", "Invalid V1 external contribution.");
936
+ }
937
+ let person = value.personAttribution;
938
+ if (!strict && isRecord2(person) && isRecord2(person.identity))
939
+ person = {
940
+ identity: { kind: person.identity.kind, key: person.identity.key, issuer: person.identity.issuer, subject: person.identity.subject },
941
+ presentation: isRecord2(person.presentation) ? { displayName: person.presentation.displayName, handle: person.presentation.handle, avatarUrl: person.presentation.avatarUrl } : person.presentation,
942
+ evidence: person.evidence
943
+ };
944
+ if (strict && isRecord2(person) && isRecord2(person.presentation) && Object.keys(person.presentation).some((key) => !["displayName", "handle", "avatarUrl"].includes(key)))
945
+ return err("invalid-input", "Unexpected presentation field.");
946
+ const decoded = person === undefined ? null : externalPersonSnapshotCodec.decode(person);
947
+ if (decoded && !decoded.ok)
948
+ return decoded;
949
+ return ok({
950
+ version: 1,
951
+ author: { subject: value.author.subject, presentation: { displayName: value.author.presentation.displayName, handle: value.author.presentation.handle, avatarUrl: value.author.presentation.avatarUrl } },
952
+ ...decoded?.ok ? { personAttribution: decoded.value } : {}
953
+ });
954
+ }
955
+ var externalContributionV1Codec = {
956
+ decode: (value) => decodeExternalContribution(value),
957
+ encode: (value) => value
958
+ };
959
+ function canonicalExternal(value) {
960
+ if (Array.isArray(value))
961
+ return `[${value.map(canonicalExternal).join(",")}]`;
962
+ if (isRecord2(value))
963
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalExternal(value[key])}`).join(",")}}`;
964
+ return JSON.stringify(value);
965
+ }
966
+ function externalContributionMethods(upstream, extension, disposed) {
967
+ const support = () => {
968
+ if (disposed())
969
+ return err("disposed", "Identity host is disposed.");
970
+ if (extension.status === "absent")
971
+ return ok(null);
972
+ if (extension.status !== "supported")
973
+ return { ok: false, error: protocolError(extension) };
974
+ const value = extension.protocol.externalContributions;
975
+ if (value === undefined)
976
+ return ok(null);
977
+ if (!isRecord2(value) || !Array.isArray(value.versions) || !value.versions.length || !value.versions.every((version) => Number.isInteger(version) && version > 0))
978
+ return err("incompatible", "Malformed external contribution capability.");
979
+ if (!value.versions.includes(1))
980
+ return ok(null);
981
+ if (!Array.isArray(value.personKinds) || value.personKinds.length !== 1 || value.personKinds[0] !== "person" || value.history !== "accepted-and-queued" || value.rollback !== "requires-capable-host" || typeof value.acceptV1 !== "function" || typeof value.lookupV1 !== "function")
982
+ return err("incompatible", "Malformed V1 external contribution capability.");
983
+ return ok(value);
984
+ };
985
+ const outcome = (raw) => {
986
+ const decoded = decodeAcceptance(raw);
987
+ if (!decoded.ok || decoded.value.status !== "submitted")
988
+ return decoded;
989
+ if (decoded.value.receipt.evidence !== "host-accepted")
990
+ return err("incompatible", "V1 needs durable native acceptance.");
991
+ const receipt = decoded.value.receipt;
992
+ const contribution = decodeExternalContribution(receipt.externalContribution, false);
993
+ return contribution.ok ? ok({ status: "submitted", receipt: { ...decoded.value.receipt, externalContribution: contribution.value } }) : err("incompatible", "Host receipt omitted or changed V1 attribution.");
994
+ };
995
+ return {
996
+ externalContributionCapabilities() {
997
+ const negotiated = support();
998
+ return !negotiated.ok ? negotiated : ok(negotiated.value ? { version: 1, personKinds: ["person"], history: "accepted-and-queued", rollback: "requires-capable-host" } : null);
999
+ },
1000
+ async sendExternalContribution(contribution, input, options) {
1001
+ const operationId = input.operationId;
1002
+ const decoded = decodeExternalContribution(contribution);
1003
+ if (!decoded.ok)
1004
+ return { status: "rejected", error: decoded.error };
1005
+ const negotiated = support();
1006
+ if (!negotiated.ok)
1007
+ return { status: "rejected", error: negotiated.error };
1008
+ if (!negotiated.value)
1009
+ return { status: "rejected", error: { code: "unsupported", message: "Native external-person attribution V1 is unavailable.", retry: "never" } };
1010
+ if (options?.signal?.aborted)
1011
+ return { status: "rejected", error: { code: "expired", message: "The send was cancelled before dispatch.", retry: "never" } };
1012
+ const frozen = JSON.parse(JSON.stringify(decoded.value));
1013
+ const wire = JSON.parse(JSON.stringify(wireInput(input, upstream.inputCodec)));
1014
+ try {
1015
+ const result = outcome(await negotiated.value.acceptV1({ contribution: frozen, input: wire }, options));
1016
+ if (!result.ok)
1017
+ return { status: "indeterminate", operationId, message: result.error.message };
1018
+ if (result.value.status === "submitted" && (result.value.receipt.operationId !== operationId || canonicalExternal(result.value.receipt.externalContribution) !== canonicalExternal(frozen))) {
1019
+ return { status: "indeterminate", operationId, message: "Host receipt did not confirm the reserved attribution." };
1020
+ }
1021
+ return result.value;
1022
+ } catch {
1023
+ return { status: "indeterminate", operationId, message: "External contribution dispatch outcome is unknown; reconcile this operation." };
1024
+ }
1025
+ },
1026
+ async lookupExternalContribution(operationId, options) {
1027
+ const negotiated = support();
1028
+ if (!negotiated.ok)
1029
+ return negotiated;
1030
+ if (!negotiated.value)
1031
+ return ok({ status: "unknown", reason: "unsupported" });
1032
+ try {
1033
+ const result = asResult(await negotiated.value.lookupV1(operationId, options));
1034
+ if (!result.ok)
1035
+ return result;
1036
+ if (isRecord2(result.value) && result.value.status === "final") {
1037
+ const decoded = outcome(result.value.outcome);
1038
+ if (decoded.ok && decoded.value.status === "submitted" && decoded.value.receipt.operationId !== operationId) {
1039
+ return err("incompatible", "Host lookup returned a receipt for another operation.");
1040
+ }
1041
+ return decoded.ok ? ok({ status: "final", outcome: decoded.value }) : decoded;
1042
+ }
1043
+ return decodeLookup(result.value);
1044
+ } catch {
1045
+ return err("unavailable", "External contribution lookup is unavailable.", "after-reconnect");
1046
+ }
1047
+ }
1048
+ };
1049
+ }
919
1050
  function inspectForkExtension(value) {
920
1051
  if (value === undefined)
921
1052
  return { status: "absent" };
@@ -928,5 +1059,6 @@ function inspectForkExtension(value) {
928
1059
  export {
929
1060
  serverSessionCodec,
930
1061
  inspectForkExtension,
1062
+ externalContributionV1Codec,
931
1063
  createHostAdapter
932
1064
  };
@@ -107,7 +107,7 @@ function isOrigin(value) {
107
107
  return value.kind === "unknown" && (value.reason === "legacy" || value.reason === "upstream-unattributed" || value.reason === "missing-source");
108
108
  }
109
109
  function isContribution(value) {
110
- return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
110
+ return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
111
111
  }
112
112
  function isCorrelation(value) {
113
113
  return isRecord(value) && idCodec("thread").decode(value.threadId).ok && isBoundedString(value.turnId) && isBoundedString(value.attemptId) && (value.toolCallId === null || isBoundedString(value.toolCallId));
@@ -184,6 +184,17 @@ var provenanceCodec = {
184
184
  return value;
185
185
  }
186
186
  };
187
+ var externalPersonSnapshotCodec = {
188
+ decode(value) {
189
+ if (!isRecord(value) || Object.keys(value).some((key) => !["identity", "presentation", "evidence"].includes(key)) || value.evidence !== "integration-asserted" || !isActorSnapshot(value) || !isRecord(value.identity) || value.identity.kind !== "person" || Object.keys(value.identity).some((key) => !["kind", "key", "issuer", "subject"].includes(key))) {
190
+ return err("invalid-input", "External person attribution requires a canonical person and integration-asserted evidence.");
191
+ }
192
+ return ok(value);
193
+ },
194
+ encode(value) {
195
+ return value;
196
+ }
197
+ };
187
198
  function defaultIdentityKey(instance) {
188
199
  return `local:${instance}`;
189
200
  }
@@ -107,7 +107,7 @@ function isOrigin(value) {
107
107
  return value.kind === "unknown" && (value.reason === "legacy" || value.reason === "upstream-unattributed" || value.reason === "missing-source");
108
108
  }
109
109
  function isContribution(value) {
110
- return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
110
+ return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
111
111
  }
112
112
  function isCorrelation(value) {
113
113
  return isRecord(value) && idCodec("thread").decode(value.threadId).ok && isBoundedString(value.turnId) && isBoundedString(value.attemptId) && (value.toolCallId === null || isBoundedString(value.toolCallId));
@@ -184,6 +184,17 @@ var provenanceCodec = {
184
184
  return value;
185
185
  }
186
186
  };
187
+ var externalPersonSnapshotCodec = {
188
+ decode(value) {
189
+ if (!isRecord(value) || Object.keys(value).some((key) => !["identity", "presentation", "evidence"].includes(key)) || value.evidence !== "integration-asserted" || !isActorSnapshot(value) || !isRecord(value.identity) || value.identity.kind !== "person" || Object.keys(value.identity).some((key) => !["kind", "key", "issuer", "subject"].includes(key))) {
190
+ return err("invalid-input", "External person attribution requires a canonical person and integration-asserted evidence.");
191
+ }
192
+ return ok(value);
193
+ },
194
+ encode(value) {
195
+ return value;
196
+ }
197
+ };
187
198
  function defaultIdentityKey(instance) {
188
199
  return `local:${instance}`;
189
200
  }
@@ -202,6 +213,7 @@ export {
202
213
  newConflictToken,
203
214
  identityKeyCodec,
204
215
  idCodec,
216
+ externalPersonSnapshotCodec,
205
217
  err,
206
218
  defaultIdentityKey
207
219
  };
@@ -187,6 +187,9 @@ function createIdentityServer(options) {
187
187
  },
188
188
  toolProvenance: (context) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.readToolProvenance(context),
189
189
  sendExternal: (author, input, readOptions) => disposed ? Promise.resolve({ status: "rejected", error: { code: "disposed", message: "Identity server is disposed.", retry: "never" } }) : host.sendExternal(author, input, readOptions),
190
+ externalContributionCapabilities: () => disposed ? err("disposed", "Identity server is disposed.") : host.externalContributionCapabilities(),
191
+ sendExternalContribution: (contribution, input, readOptions) => disposed ? Promise.resolve({ status: "rejected", error: { code: "disposed", message: "Identity server is disposed.", retry: "never" } }) : host.sendExternalContribution(contribution, input, readOptions),
192
+ lookupExternalContribution: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.lookupExternalContribution(operationId, readOptions),
190
193
  lookupOperation: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.lookupOperation(operationId, readOptions),
191
194
  dispose
192
195
  };
@@ -107,7 +107,7 @@ function isOrigin(value) {
107
107
  return value.kind === "unknown" && (value.reason === "legacy" || value.reason === "upstream-unattributed" || value.reason === "missing-source");
108
108
  }
109
109
  function isContribution(value) {
110
- return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
110
+ return isRecord(value) && isContributionReference(value.reference) && isOrigin(value.author) && (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && (value.latestEditor === null || isActorSnapshot(value.latestEditor)) && isBoundedString(value.acceptedAt, 256) && Array.isArray(value.mentionedPeople) && value.mentionedPeople.every(isPersonReference);
111
111
  }
112
112
  function isCorrelation(value) {
113
113
  return isRecord(value) && idCodec("thread").decode(value.threadId).ok && isBoundedString(value.turnId) && isBoundedString(value.attemptId) && (value.toolCallId === null || isBoundedString(value.toolCallId));
@@ -184,6 +184,17 @@ var provenanceCodec = {
184
184
  return value;
185
185
  }
186
186
  };
187
+ var externalPersonSnapshotCodec = {
188
+ decode(value) {
189
+ if (!isRecord(value) || Object.keys(value).some((key) => !["identity", "presentation", "evidence"].includes(key)) || value.evidence !== "integration-asserted" || !isActorSnapshot(value) || !isRecord(value.identity) || value.identity.kind !== "person" || Object.keys(value.identity).some((key) => !["kind", "key", "issuer", "subject"].includes(key))) {
190
+ return err("invalid-input", "External person attribution requires a canonical person and integration-asserted evidence.");
191
+ }
192
+ return ok(value);
193
+ },
194
+ encode(value) {
195
+ return value;
196
+ }
197
+ };
187
198
  function defaultIdentityKey(instance) {
188
199
  return `local:${instance}`;
189
200
  }
@@ -378,7 +389,7 @@ function isContribution2(value) {
378
389
  if (!isRecord2(origin))
379
390
  return false;
380
391
  const validOrigin = origin.kind === "person" ? snapshot(origin.actor) && isRecord2(origin.actor) && isPersonIdentity(origin.actor.identity) : origin.kind === "machine" ? snapshot(origin.actor) && isRecord2(origin.actor) && isMachineIdentity(origin.actor.identity) : origin.kind === "external" ? snapshot(origin.actor) && isRecord2(origin.actor) && isExternalIdentity(origin.actor.identity) : origin.kind === "agent" ? origin.agentId === null || idCodec("agent").decode(origin.agentId).ok : origin.kind === "system" ? typeof origin.reason === "string" && origin.reason.length > 0 && origin.reason.length <= 1024 : origin.kind === "unknown" && (origin.reason === "legacy" || origin.reason === "upstream-unattributed" || origin.reason === "missing-source");
381
- return validOrigin && (value.latestEditor === null || snapshot(value.latestEditor));
392
+ return (value.personAttribution === undefined || externalPersonSnapshotCodec.decode(value.personAttribution).ok) && validOrigin && (value.latestEditor === null || snapshot(value.latestEditor));
382
393
  }
383
394
  function decodeEvidencePage(value, decodeItem) {
384
395
  if (!isRecord2(value) || typeof value.status !== "string")
@@ -713,6 +724,7 @@ function createHostAdapter(options) {
713
724
  });
714
725
  const contexts = new WeakMap;
715
726
  return {
727
+ ...externalContributionMethods(upstream, extension, () => disposed),
716
728
  pluginId: upstream.pluginId,
717
729
  instanceId: upstream.instanceId,
718
730
  session: (context) => upstream.session(context),
@@ -758,6 +770,7 @@ function createHostAdapter(options) {
758
770
  const error = protocolError(extension);
759
771
  const unavailableResult = async () => ({ ok: false, error });
760
772
  return {
773
+ ...externalContributionMethods(upstream, extension, () => disposed),
761
774
  pluginId: upstream.pluginId,
762
775
  instanceId: upstream.instanceId,
763
776
  session: async () => ({ status: error.code === "incompatible" ? "incompatible" : "unavailable", instanceId: upstream.instanceId, error }),
@@ -833,6 +846,7 @@ function createHostAdapter(options) {
833
846
  });
834
847
  const scopes = new WeakMap;
835
848
  return {
849
+ ...externalContributionMethods(upstream, extension, () => disposed),
836
850
  pluginId: upstream.pluginId,
837
851
  instanceId: protocol.instanceId,
838
852
  async session(context) {
@@ -919,6 +933,123 @@ function createHostAdapter(options) {
919
933
  }
920
934
  };
921
935
  }
936
+ function decodeExternalContribution(value, strict = true) {
937
+ if (!isRecord2(value) || value.version !== 1 || !isRecord2(value.author) || strict && Object.keys(value).some((key) => !["version", "author", "personAttribution"].includes(key)) || strict && Object.keys(value.author).some((key) => !["subject", "presentation"].includes(key)) || strict && isRecord2(value.author.presentation) && Object.keys(value.author.presentation).some((key) => !["displayName", "handle", "avatarUrl"].includes(key)) || !idCodec("subject").decode(value.author.subject).ok || !isProfilePresentation(value.author.presentation)) {
938
+ return err("invalid-input", "Invalid V1 external contribution.");
939
+ }
940
+ let person = value.personAttribution;
941
+ if (!strict && isRecord2(person) && isRecord2(person.identity))
942
+ person = {
943
+ identity: { kind: person.identity.kind, key: person.identity.key, issuer: person.identity.issuer, subject: person.identity.subject },
944
+ presentation: isRecord2(person.presentation) ? { displayName: person.presentation.displayName, handle: person.presentation.handle, avatarUrl: person.presentation.avatarUrl } : person.presentation,
945
+ evidence: person.evidence
946
+ };
947
+ if (strict && isRecord2(person) && isRecord2(person.presentation) && Object.keys(person.presentation).some((key) => !["displayName", "handle", "avatarUrl"].includes(key)))
948
+ return err("invalid-input", "Unexpected presentation field.");
949
+ const decoded = person === undefined ? null : externalPersonSnapshotCodec.decode(person);
950
+ if (decoded && !decoded.ok)
951
+ return decoded;
952
+ return ok({
953
+ version: 1,
954
+ author: { subject: value.author.subject, presentation: { displayName: value.author.presentation.displayName, handle: value.author.presentation.handle, avatarUrl: value.author.presentation.avatarUrl } },
955
+ ...decoded?.ok ? { personAttribution: decoded.value } : {}
956
+ });
957
+ }
958
+ var externalContributionV1Codec = {
959
+ decode: (value) => decodeExternalContribution(value),
960
+ encode: (value) => value
961
+ };
962
+ function canonicalExternal(value) {
963
+ if (Array.isArray(value))
964
+ return `[${value.map(canonicalExternal).join(",")}]`;
965
+ if (isRecord2(value))
966
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalExternal(value[key])}`).join(",")}}`;
967
+ return JSON.stringify(value);
968
+ }
969
+ function externalContributionMethods(upstream, extension, disposed) {
970
+ const support = () => {
971
+ if (disposed())
972
+ return err("disposed", "Identity host is disposed.");
973
+ if (extension.status === "absent")
974
+ return ok(null);
975
+ if (extension.status !== "supported")
976
+ return { ok: false, error: protocolError(extension) };
977
+ const value = extension.protocol.externalContributions;
978
+ if (value === undefined)
979
+ return ok(null);
980
+ if (!isRecord2(value) || !Array.isArray(value.versions) || !value.versions.length || !value.versions.every((version) => Number.isInteger(version) && version > 0))
981
+ return err("incompatible", "Malformed external contribution capability.");
982
+ if (!value.versions.includes(1))
983
+ return ok(null);
984
+ if (!Array.isArray(value.personKinds) || value.personKinds.length !== 1 || value.personKinds[0] !== "person" || value.history !== "accepted-and-queued" || value.rollback !== "requires-capable-host" || typeof value.acceptV1 !== "function" || typeof value.lookupV1 !== "function")
985
+ return err("incompatible", "Malformed V1 external contribution capability.");
986
+ return ok(value);
987
+ };
988
+ const outcome = (raw) => {
989
+ const decoded = decodeAcceptance(raw);
990
+ if (!decoded.ok || decoded.value.status !== "submitted")
991
+ return decoded;
992
+ if (decoded.value.receipt.evidence !== "host-accepted")
993
+ return err("incompatible", "V1 needs durable native acceptance.");
994
+ const receipt = decoded.value.receipt;
995
+ const contribution = decodeExternalContribution(receipt.externalContribution, false);
996
+ return contribution.ok ? ok({ status: "submitted", receipt: { ...decoded.value.receipt, externalContribution: contribution.value } }) : err("incompatible", "Host receipt omitted or changed V1 attribution.");
997
+ };
998
+ return {
999
+ externalContributionCapabilities() {
1000
+ const negotiated = support();
1001
+ return !negotiated.ok ? negotiated : ok(negotiated.value ? { version: 1, personKinds: ["person"], history: "accepted-and-queued", rollback: "requires-capable-host" } : null);
1002
+ },
1003
+ async sendExternalContribution(contribution, input, options) {
1004
+ const operationId = input.operationId;
1005
+ const decoded = decodeExternalContribution(contribution);
1006
+ if (!decoded.ok)
1007
+ return { status: "rejected", error: decoded.error };
1008
+ const negotiated = support();
1009
+ if (!negotiated.ok)
1010
+ return { status: "rejected", error: negotiated.error };
1011
+ if (!negotiated.value)
1012
+ return { status: "rejected", error: { code: "unsupported", message: "Native external-person attribution V1 is unavailable.", retry: "never" } };
1013
+ if (options?.signal?.aborted)
1014
+ return { status: "rejected", error: { code: "expired", message: "The send was cancelled before dispatch.", retry: "never" } };
1015
+ const frozen = JSON.parse(JSON.stringify(decoded.value));
1016
+ const wire = JSON.parse(JSON.stringify(wireInput(input, upstream.inputCodec)));
1017
+ try {
1018
+ const result = outcome(await negotiated.value.acceptV1({ contribution: frozen, input: wire }, options));
1019
+ if (!result.ok)
1020
+ return { status: "indeterminate", operationId, message: result.error.message };
1021
+ if (result.value.status === "submitted" && (result.value.receipt.operationId !== operationId || canonicalExternal(result.value.receipt.externalContribution) !== canonicalExternal(frozen))) {
1022
+ return { status: "indeterminate", operationId, message: "Host receipt did not confirm the reserved attribution." };
1023
+ }
1024
+ return result.value;
1025
+ } catch {
1026
+ return { status: "indeterminate", operationId, message: "External contribution dispatch outcome is unknown; reconcile this operation." };
1027
+ }
1028
+ },
1029
+ async lookupExternalContribution(operationId, options) {
1030
+ const negotiated = support();
1031
+ if (!negotiated.ok)
1032
+ return negotiated;
1033
+ if (!negotiated.value)
1034
+ return ok({ status: "unknown", reason: "unsupported" });
1035
+ try {
1036
+ const result = asResult(await negotiated.value.lookupV1(operationId, options));
1037
+ if (!result.ok)
1038
+ return result;
1039
+ if (isRecord2(result.value) && result.value.status === "final") {
1040
+ const decoded = outcome(result.value.outcome);
1041
+ if (decoded.ok && decoded.value.status === "submitted" && decoded.value.receipt.operationId !== operationId) {
1042
+ return err("incompatible", "Host lookup returned a receipt for another operation.");
1043
+ }
1044
+ return decoded.ok ? ok({ status: "final", outcome: decoded.value }) : decoded;
1045
+ }
1046
+ return decodeLookup(result.value);
1047
+ } catch {
1048
+ return err("unavailable", "External contribution lookup is unavailable.", "after-reconnect");
1049
+ }
1050
+ }
1051
+ };
1052
+ }
922
1053
  function inspectForkExtension(value) {
923
1054
  if (value === undefined)
924
1055
  return { status: "absent" };
@@ -2187,6 +2318,9 @@ function createIdentityServer(options) {
2187
2318
  },
2188
2319
  toolProvenance: (context) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.readToolProvenance(context),
2189
2320
  sendExternal: (author, input, readOptions) => disposed ? Promise.resolve({ status: "rejected", error: { code: "disposed", message: "Identity server is disposed.", retry: "never" } }) : host.sendExternal(author, input, readOptions),
2321
+ externalContributionCapabilities: () => disposed ? err("disposed", "Identity server is disposed.") : host.externalContributionCapabilities(),
2322
+ sendExternalContribution: (contribution, input, readOptions) => disposed ? Promise.resolve({ status: "rejected", error: { code: "disposed", message: "Identity server is disposed.", retry: "never" } }) : host.sendExternalContribution(contribution, input, readOptions),
2323
+ lookupExternalContribution: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.lookupExternalContribution(operationId, readOptions),
2190
2324
  lookupOperation: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity server is disposed.")) : host.lookupOperation(operationId, readOptions),
2191
2325
  dispose
2192
2326
  };
@@ -2708,6 +2842,17 @@ function createBbIdentityServerBinding(options) {
2708
2842
  const renderedInput = upstream.value.externalMessageRendering === "host" ? renderExternalPrompt(upstream.value.driver.pluginId, author, input.input) : input.input;
2709
2843
  return server.sendExternal(author, { ...input, input: renderedInput }, readOptions);
2710
2844
  },
2845
+ externalContributionCapabilities: () => disposed ? err("disposed", "Identity binding is disposed.") : server.externalContributionCapabilities(),
2846
+ async sendExternalContribution(contribution, input, readOptions) {
2847
+ if (disposed)
2848
+ return { status: "rejected", error: { code: "disposed", message: "Identity binding is disposed.", retry: "never" } };
2849
+ const decoded = externalContributionV1Codec.decode(contribution);
2850
+ if (!decoded.ok)
2851
+ return { status: "rejected", error: decoded.error };
2852
+ const renderedInput = upstream.value.externalMessageRendering === "host" ? renderExternalPrompt(upstream.value.driver.pluginId, decoded.value.author, input.input) : input.input;
2853
+ return server.sendExternalContribution(decoded.value, { ...input, input: renderedInput }, readOptions);
2854
+ },
2855
+ lookupExternalContribution: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity binding is disposed.")) : server.lookupExternalContribution(operationId, readOptions),
2711
2856
  lookupOperation: (operationId, readOptions) => disposed ? Promise.resolve(err("disposed", "Identity binding is disposed.")) : server.lookupOperation(operationId, readOptions),
2712
2857
  history: guardedHistory
2713
2858
  };
package/host.d.ts CHANGED
@@ -37,6 +37,29 @@ export type HostInvalidation =
37
37
  | { readonly kind: 'participants'; readonly threadIds: readonly string[] }
38
38
  | { readonly kind: 'disconnected' | 'reconnected' | 'disposed' };
39
39
  export interface ExternalAuthorInput { readonly subject: string; readonly presentation: ProfilePresentation; }
40
+ export interface ExternalContributionV1 {
41
+ readonly version: 1;
42
+ readonly author: ExternalAuthorInput;
43
+ readonly personAttribution?: import('./model.js').ExternalPersonSnapshotV1;
44
+ }
45
+ export declare const externalContributionV1Codec: Codec<ExternalContributionV1>;
46
+ export type ExternalContributionOutcomeV1 = Exclude<AcceptanceOutcome, { status: 'submitted' }> | {
47
+ readonly status: 'submitted';
48
+ readonly receipt: Extract<import('./model.js').AcceptanceReceipt, { evidence: 'host-accepted' }> & {
49
+ readonly externalContribution: ExternalContributionV1;
50
+ };
51
+ };
52
+ /** Context-free support; native storage written by V1 requires a capable host on rollback. */
53
+ export interface ExternalContributionCapabilityV1 {
54
+ readonly version: 1; readonly personKinds: readonly ['person'];
55
+ readonly history: 'accepted-and-queued'; readonly rollback: 'requires-capable-host';
56
+ }
57
+ export interface ExternalContributionProtocolV1<Input> {
58
+ readonly versions: readonly number[]; readonly personKinds: readonly ['person'];
59
+ readonly history: 'accepted-and-queued'; readonly rollback: 'requires-capable-host';
60
+ acceptV1(request: { readonly contribution: Wire<ExternalContributionV1>; readonly input: Wire<SendInput<Input>> }, options?: ReadOptions): Promise<Wire<ExternalContributionOutcomeV1>>;
61
+ lookupV1(operationId: string, options?: ReadOptions): Promise<Wire<Result<OperationLookup<ExternalContributionOutcomeV1>>>>;
62
+ }
40
63
  export interface IdentityHost<RequestContext, ToolContext, Input> extends Disposable {
41
64
  readonly pluginId: PluginId;
42
65
  readonly instanceId: InstanceId;
@@ -51,6 +74,10 @@ export interface IdentityHost<RequestContext, ToolContext, Input> extends Dispos
51
74
  /** Checks synchronously before upstream dispatch; enhanced host checks again inside acceptance. */
52
75
  send(request: LiveRequest, input: SendInput<Input>, options?: ReadOptions): Promise<AcceptanceOutcome>;
53
76
  sendExternal(author: ExternalAuthorInput, input: SendInput<Input>, options?: ReadOptions): Promise<AcceptanceOutcome>;
77
+ externalContributionCapabilities(): Result<ExternalContributionCapabilityV1 | null>;
78
+ sendExternalContribution(contribution: ExternalContributionV1, input: SendInput<Input>, options?: ReadOptions): Promise<ExternalContributionOutcomeV1>;
79
+ lookupExternalContribution(operationId: SendInput<Input>['operationId'], options?: ReadOptions): Promise<Result<OperationLookup<ExternalContributionOutcomeV1>>>;
80
+
54
81
  lookupOperation(operationId: SendInput<Input>['operationId'], options?: ReadOptions): Promise<Result<OperationLookup<AcceptanceOutcome>>>;
55
82
  forward(request: LiveRequest, destinationPlugin: string, method: string, input: import('./model.js').Json): Promise<unknown>;
56
83
  registerProvider(provider: IdentityProvider): Promise<Result<ProviderRegistrationV1>>;
@@ -149,6 +176,7 @@ export interface ForkIdentityProvider {
149
176
  /** Core implements these host-owned operations, not IdentityHost's fallback/directory composition. */
150
177
  export interface ForkIdentityProtocolV1<RequestContext, ToolContext, Input> {
151
178
  readonly version: 1;
179
+ readonly externalContributions?: ExternalContributionProtocolV1<Input>;
152
180
  /** Persisted core storage namespace; stable across plugin replacement and never request-derived. */
153
181
  readonly instanceId: string;
154
182
  /** Enables this plugin generation's producer-owned external message rendering in the SDK host. */
package/model.d.ts CHANGED
@@ -99,10 +99,17 @@ export interface ContributionReference {
99
99
  readonly threadId: ThreadId;
100
100
  readonly contributionId: ContributionId;
101
101
  }
102
+ /** Frozen integration assertion; V1 supports canonical people, not default-user. */
103
+ export type ExternalPersonSnapshotV1 = ActorSnapshot & {
104
+ readonly identity: Extract<IdentityReference, { kind: 'person' }>;
105
+ readonly evidence: 'integration-asserted';
106
+ };
107
+ export declare const externalPersonSnapshotCodec: Codec<ExternalPersonSnapshotV1>;
102
108
  /** A contribution is independent of delivery grouping and execution attempts. */
103
109
  export interface Contribution {
104
110
  readonly reference: ContributionReference;
105
111
  readonly author: Origin;
112
+ readonly personAttribution?: ExternalPersonSnapshotV1;
106
113
  readonly latestEditor: ActorSnapshot | null;
107
114
  readonly acceptedAt: string;
108
115
  readonly mentionedPeople: readonly PersonReference[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phosphorco/bb-identity",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Portable identity, provenance, and state contracts for BB integrations.",
package/server.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { AcceptanceOutcome, ActorReference, ActorSnapshot, Codec, Directory, Disposable, ExecutionProvenance,
3
3
  IdentityKey, InstanceId, OperationId, OperationLookup, ParticipantReader, PersonReference,
4
4
  PersonSendInput, ReadOptions, RequestExpectation, Result, SendInput, TargetSelection, Unsubscribe } from './model.js';
5
- import type { ExternalAuthorInput, IdentityHost, IdentityCapabilities, ServerSession, HostInvalidation } from './host.js';
5
+ import type { ExternalAuthorInput, ExternalContributionV1, ExternalContributionOutcomeV1, ExternalContributionCapabilityV1, IdentityHost, IdentityCapabilities, ServerSession, HostInvalidation } from './host.js';
6
6
 
7
7
  declare const resolvedTarget: unique symbol;
8
8
  export interface TargetSnapshot {
@@ -61,6 +61,10 @@ export interface IdentityServer<RequestContext, ToolContext, Input> extends Disp
61
61
  personRequest(context: RequestContext, options?: ReadOptions): Promise<Result<PersonRequest<Input>>>;
62
62
  toolProvenance(context: ToolContext): Promise<Result<ExecutionProvenance>>;
63
63
  sendExternal(author: ExternalAuthorInput, input: SendInput<Input>, options?: ReadOptions): Promise<AcceptanceOutcome>;
64
+ externalContributionCapabilities(): Result<ExternalContributionCapabilityV1 | null>;
65
+ sendExternalContribution(contribution: ExternalContributionV1, input: SendInput<Input>, options?: ReadOptions): Promise<ExternalContributionOutcomeV1>;
66
+ lookupExternalContribution(operationId: SendInput<Input>['operationId'], options?: ReadOptions): Promise<Result<OperationLookup<ExternalContributionOutcomeV1>>>;
67
+
64
68
  /** Lookup reports retained operation outcomes; IDs are scoped to this instance/plugin. */
65
69
  lookupOperation(operationId: OperationId, options?: ReadOptions): Promise<Result<OperationLookup<AcceptanceOutcome>>>;
66
70
  }