/**
* DEMO-GRADE until a pattern's reads carry the seal's input witness (labs
* L14b): its release rule cannot require that witness yet, so it protects a
* member's stance from honest members' code only, and the host's confirmation
* warns the member so.
*
* A room whose members' stances are sealed into its policy's custody, and
* whose policy releases only what one function of its own module projects
* from them.
*
* A member reviews a stance in the trusted host's `cf-custody-seal` dialog,
* which a room binds as ``, with the member's own draft and
* source policy from the member's home space. The host seals the stance into
* the instance's box: one document in this room's space, which every
* member's seal writes an entry into and which names no member. The pattern
* never holds a member's DID, the box's address, or its own policy's
* reference, and needs none of them:
*
* - The terms name each seat by a cell whose stored label attests one
* principal, as a member's profile does. The host resolves each to the DID
* it seals.
* - `policy` is declared `PolicyOf` the room's rules, so its label carries
* the policy's reference with this room as its subject, and the host reads
* the reference from there.
* - Once a member seals, the host writes a link to the box into `box`.
*
* Every entry of the box is labeled with the room's policy, and the box
* itself with the policy or the room's readers, so what the pattern computes
* from it is shown to anyone only through `releaseChoice`. That rule drops
* the policy from what `projectChoice` returns, one of the listed answers,
* which leaves the room's own readers able to see it; a member's rating, and
* anything else computed from the box, stays sealed. The stances hold a
* bounded array of closed ratings, one per option, which is the shape the
* seal admits for a multiple choice.
*
* The rule names the projector by its identity alone, which is weaker than
* it looks: `box` is ordinary room data, so a member's code can link it to a
* document of its own that mixes another member's entry with entries it made
* up, and learn that entry from the answers the rule releases. A rule that
* also requires an input witness, `TransformedBy{builtin cfc-custody-seal}` on
* everything confidential the projector read, is the shape a rule over sealed
* custody is meant to take and would refuse those, but the witness does not
* reach what a lift reads through its argument, so such a rule releases
* nothing a pattern computes; see `docs/specs/cfc-custody-seal.md`. Until it
* does, this room keeps a member's rating from honest members' code only.
*/
import {
type Confidential,
Default,
handler,
lift,
NAME,
pattern,
Stream,
UI,
type VNode,
Writable,
} from "commonfabric";
import {
exchangeRule,
exchangeRules,
type PolicyOf,
THIS_POLICY,
} from "commonfabric/cfc";
export const releaseChoice = exchangeRule({
appliesTo: THIS_POLICY,
pre: {
integrity: [{
type: "https://commonfabric.org/cfc/atom/TransformedBy",
identity: {
kind: "verified",
moduleIdentity: THIS_POLICY.moduleIdentity,
symbol: "projectChoice",
},
}],
},
post: { dropClause: true },
});
export const custodyRules = exchangeRules([releaseChoice]);
/** Labeled with the room's policy: only `projectChoice`'s output leaves. */
export type Sealed = Confidential<
T,
readonly [PolicyOf]
>;
/** The options a stance rates, in the order its ratings list them. */
export const OPTIONS = ["pizza", "sushi", "tacos"] as const;
/** The answer when no option suits every seat, or the room is incomplete. */
export const NO_AGREEMENT = "no agreement";
export type Rating = "no" | "maybe" | "yes";
/** One member's sealed stance: a rating per option. */
export interface Stance {
ratings: Rating[];
}
/** One entry of the box, as the seal writes it. */
export interface BoxEntry {
instance: string;
terms: string;
stance: Stance;
}
/** The instance's box: entries keyed by a blinded key that names no member. */
export type Box = Record;
/**
* The stance schema the terms carry: a closed rating per option, which the
* seal admits as an array whose one element schema is closed and whose
* length is bounded.
*/
export const STANCE_SCHEMA = {
type: "object",
properties: {
ratings: {
type: "array",
items: { enum: ["no", "maybe", "yes"] },
minItems: OPTIONS.length,
maxItems: OPTIONS.length,
},
},
required: ["ratings"],
additionalProperties: false,
};
/**
* The released computation: the option no seat rated `no` that the most
* seats rated `yes`, the earliest listed winning a tie. It answers
* {@link NO_AGREEMENT} unless every entry was sealed under the same terms and
* there is one entry per seat, and it never throws.
*/
export const projectChoice = lift((box: Box | undefined): string => {
const entries = Object.values(box ?? {});
const terms = entries[0]?.terms;
if (
terms === undefined || entries.some((entry) => entry?.terms !== terms)
) return NO_AGREEMENT;
let seats: unknown;
try {
seats = (JSON.parse(terms) as { seats?: unknown }).seats;
} catch {
return NO_AGREEMENT;
}
if (!Array.isArray(seats) || seats.length !== entries.length) {
return NO_AGREEMENT;
}
let best: string = NO_AGREEMENT;
let bestYes = -1;
OPTIONS.forEach((option, index) => {
const ratings = entries.map((entry) => entry?.stance?.ratings?.[index]);
if (ratings.some((rating) => rating !== "yes" && rating !== "maybe")) {
return;
}
const yes = ratings.filter((rating) => rating === "yes").length;
if (yes > bestYes) {
best = option;
bestYes = yes;
}
});
return best;
});
/** Defined beside `projectChoice` but not named by the rule, so not released. */
export const firstRating = lift((box: Box | undefined): string =>
Object.values(box ?? {})[0]?.stance?.ratings?.[0] ?? ""
);
/** The room's terms, as `propose` writes them. */
export interface CustodyTerms {
question: string;
answers: string[];
/** A cell per seat, whose stored label attests the seat's member. */
seats: unknown[];
stanceSchema: typeof STANCE_SCHEMA;
}
interface CustodyProjectorInput {
terms: Writable>;
/**
* Declared with the room's policy, so that once written its label carries
* the policy's reference, with this room as its subject, for the host to
* read. It has no default: `propose` writes it.
*/
policy: Writable>;
/**
* Receives a link to the instance's box from the host once a seat seals.
* It declares no label of its own: every entry of the box already carries
* the room's policy, and a read through the link carries it.
*
* TODO(custody-box-link): declaring `Record>` here
* is refused. Repro: give `box` that type, then write a link to the sealed
* box into it as `cf-custody-seal` does, a blind UI write
* (`runtime.commitUiCellWrite(cell, link, { blind: true })`); commit
* preparation crashes with `type changed incompatibly at /box: ["object"]
* -> ["array"]`, as the schema merge sets the input's record schema
* against an array schema. `integration/cfc-custody-projector.test.ts`
* makes that write; change this type back to reproduce.
*/
box: Writable>>;
}
export interface CustodyProjectorOutput {
[NAME]: string;
[UI]: VNode;
terms: CustodyTerms | null;
policy: Sealed;
box: Box;
/** The projected answer: a room reader sees it once the rule releases it. */
choice: string;
/** One member's sealed rating, which no rule releases. */
rating: string;
propose: Stream<{ seats: unknown[] }>;
}
/**
* Writes the room's terms, naming each seat by the cell the event carries,
* and declares the room's policy on `policy`.
*/
const propose = handler<
{ seats: unknown[] },
{ terms: Writable; policy: Writable> }
>(({ seats }, { terms, policy }) => {
terms.set({
question: "Where should we eat?",
answers: [...OPTIONS, NO_AGREEMENT],
seats,
stanceSchema: STANCE_SCHEMA,
});
policy.set(true as Sealed);
});
const CustodyProjector = pattern<
CustodyProjectorInput,
CustodyProjectorOutput
>(({ terms, policy, box }) => {
const choice = projectChoice(box);
const rating = firstRating(box);
return {
[NAME]: "Sealed custody projector",
[UI]: (
The room's choice
{choice}
),
terms,
policy,
box,
choice,
rating,
propose: propose({ terms, policy }),
};
});
export default CustodyProjector;