@jinn-network/task-posting 0.0.0 → 0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b

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
@@ -1 +1,96 @@
1
- Placeholder. Real versions come from stack-npm-publish.yml.
1
+ # @jinn-network/task-posting
2
+
3
+ > Candidate in `implementations-v1`. Eligible for receipt-gated canary; that is not independent ratification.
4
+
5
+ Supply policy for marketplace posting: which pool entries post, when, at what terms, under whose
6
+ identity, with the escrow surfaced before spending.
7
+
8
+ `planPosting(pool, policy)` is pure — a materialized pool listing plus a policy in, a
9
+ `PostingPlan` out, carrying per-entry and total escrow. `executePosting(deps, plan)` surfaces that
10
+ plan, requires approval before spending, and posts through an injected requester backend.
11
+ Explicit post is the default; auto-post is an opt-in standing policy flag with the same visibility
12
+ in a log line.
13
+
14
+ The plan is the replay unit: the sealed dispatch Submission is a function of the plan alone, so
15
+ re-executing the same plan produces byte-identical Submission bytes, the same broadcast-intent
16
+ key, and a replayed outcome rather than a second post.
17
+
18
+ ## Requester posting boundary
19
+
20
+ This package owns supply **policy** only. `PostingDeps.backend` is the existing
21
+ `MarketplaceRequesterBackend` from `@jinn-network/marketplace-binding`. It owns exact Task and
22
+ Submission admission, requester/idempotency scope, the posting WAL, recovered outcome
23
+ reconciliation and the actual transaction. This package never receives raw chain configuration,
24
+ wallet/broadcast ports, or a direct `postTask` function. The host must compose the backend with the
25
+ same creator and terms used to construct the plan; those configured venue inputs do not cross the
26
+ per-post operation boundary.
27
+
28
+ `executePosting` calls `backend.post(taskBytes, submissionBytes)` only after the plan is surfaced,
29
+ approval is granted and the complete batch passes its pure preflight. A recovered backend outcome
30
+ is accepted exactly like the original result. A retryable unresolved operation is reported as
31
+ uncertain and is never retried here; the host may invoke `backend.recoverPosting()` before a later
32
+ policy run. No separate work-client package or wrapper is required.
33
+
34
+ ## Reported to the program: F-C5-8 (resolved — ruling R10)
35
+
36
+ **The pool listing this package is pinned to consume is not `PostingPoolEntry`, and one field of
37
+ `PostingPoolEntry` has no producer anywhere in the stack.** The C5 plan's Task B5 pins
38
+ `(entry: PoolListing[number]) => PostingPoolEntry` and instructs a stop-and-report if it does not
39
+ compile (program §5 contract 11). It does not compile. This is that report.
40
+
41
+ The task-derivation `SupplyPool.list()` yields `PoolEntrySummary`:
42
+
43
+ | `PostingPoolEntry` needs | C4 has |
44
+ | --- | --- |
45
+ | `taskBytes` | on `PoolEntry` via `SupplyPool.get()`, not on the listing element |
46
+ | `admissionReceiptDigest` | the same value, named `receiptDigest` |
47
+ | `evaluationSpecPublic` | not modelled — no package in the stack computes it |
48
+
49
+ The plan's own pre-flight gate accepts the first row (it stops only when the pool exposes neither
50
+ the bytes "or a way to read them" nor a per-entry receipt digest). The other two are unreconciled
51
+ between C4's pool contract and spec §8's posting inputs.
52
+
53
+ `evaluationSpecPublic` is the load-bearing one: it is the sole gate implementing D5 ("v1 posts
54
+ public-specification evaluation legs only") in both `planPosting` (the `evaluation-not-public`
55
+ skip) and `buildDispatchSubmission` (`assertPublicSpecEvaluationLeg`). A boolean nobody produces
56
+ makes every D5 refusal a caller's claim rather than a fact about the sealed bytes.
57
+
58
+ **What this package does.** `PostingPoolEntry` is untouched — it is exactly what the
59
+ plan pins, and it was not widened. The reconciliation lives in one named, tested adapter,
60
+ `postingPoolEntry(entry, options)`, which carries C4's `receiptDigest` into
61
+ `admissionReceiptDigest` and **derives** `evaluationSpecPublic` from the sealed EvaluationSpec
62
+ bytes the entry's own digest addresses: D5's stamping writes `accessClass: "public"` onto every
63
+ access-classified descriptor at seal time, and `evaluationSpecIsPublic` reads those stamps back,
64
+ failing closed on an unstamped specification, a private stamp, a non-string stamp, or bytes that
65
+ are not a JSON document. `src/pool-shape.test.ts` is the package's sole, test-only dependency on
66
+ task derivation; it pins C4's stored entry against the adapter input type at compile time, so a
67
+ rename on either side stops the build.
68
+
69
+ **How the program ruled (R10).** The C5 plan was wrong on every count; C4 is unchanged.
70
+
71
+ 1. **`receiptDigest` stays C4's name** — it is what the program pinned (§4, ruling R5). Posting
72
+ renames at its own boundary. The plan's alternative, renaming C4 to match the plan's guess,
73
+ would have churned a pinned interface to spare this package one adapter.
74
+ 2. **`evaluationSpecPublic` is derived here, permanently** — never modelled on the pool entry and
75
+ never carried. Deriving it from the sealed bytes is what makes a D5 refusal a fact about the
76
+ specification rather than a claim by whoever produced the entry; a carried boolean would be a
77
+ silent downgrade of the gate. `src/pool-shape.test.ts` pin (d) fails the build if any producer
78
+ grows such a field, and the fix in that case is to keep deriving, not to start trusting it.
79
+ 3. **The adapter stays on the public surface.** `postingPoolEntry`, `evaluationSpecIsPublic`, and
80
+ `SuppliedPoolEntry` are exported: the tier-4 composition is the only place holding both a pool
81
+ and this application, so it is the caller that needs them, and exporting the tested adapter is
82
+ what stops each composition from inventing a looser one. The program's §4 "C5 produces" list is
83
+ amended accordingly.
84
+
85
+ The plan's Task B5 identity assertion is likewise amended: it pins the two-step join
86
+ (`SupplyPool.get()` → `SuppliedPoolEntry` → `PostingPoolEntry`) rather than the listing element,
87
+ because a listing that carried every pair's sealed bytes would be the wrong contract for a pool
88
+ that exists to be enumerated cheaply.
89
+
90
+ ## What v1 does not do
91
+
92
+ - No pricing engine — terms are operator-supplied (design §12).
93
+ - No derivation, no admission, no attestation reading. Posting carries the admission receipt by
94
+ digest and never re-decides admission.
95
+ - No private evaluation material: v1 posts public-specification evaluation legs only, and
96
+ `capabilityGrants` is never populated (design §8, D5).
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The one shape a record-body digest may take in this package: `sha256:` plus 64 lower-case hex
3
+ * characters (program §5 contract 6). Bare hex is the in-toto DigestSet form and the on-chain
4
+ * `bytes32` anchor form; the two are not interchangeable, and every conversion between them is
5
+ * explicit and checked at the point of conversion.
6
+ */
7
+ export declare const PREFIXED_SHA256_PATTERN: RegExp;
8
+ /**
9
+ * Fails closed on any digest that is not in the canonical prefixed form. Callers pass the field
10
+ * name so the refusal names the input the operator has to fix, not just its value.
11
+ */
12
+ export declare function assertPrefixedSha256(digest: string, field: string): asserts digest is `sha256:${string}`;
package/dist/digest.js ADDED
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * The one shape a record-body digest may take in this package: `sha256:` plus 64 lower-case hex
4
+ * characters (program §5 contract 6). Bare hex is the in-toto DigestSet form and the on-chain
5
+ * `bytes32` anchor form; the two are not interchangeable, and every conversion between them is
6
+ * explicit and checked at the point of conversion.
7
+ */
8
+ export const PREFIXED_SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
9
+ /**
10
+ * Fails closed on any digest that is not in the canonical prefixed form. Callers pass the field
11
+ * name so the refusal names the input the operator has to fix, not just its value.
12
+ */
13
+ export function assertPrefixedSha256(digest, field) {
14
+ if (!PREFIXED_SHA256_PATTERN.test(digest)) {
15
+ throw new Error(`${field} ${JSON.stringify(digest)} is not a sha256:-prefixed lower-case 64-hex digest`);
16
+ }
17
+ }
@@ -0,0 +1,24 @@
1
+ import type { PostingPlan, PostingPlanEntry, PostingPoolEntry } from "./types.js";
2
+ /** Namespace for the identifiers derived below; part of the byte-stability contract. */
3
+ export declare const POSTING_SUBMISSION_NAMESPACE: "jinn:task-posting:submission:v1";
4
+ /**
5
+ * v1 posts public-specification evaluation legs only (design §8, D5). Grant hosting, minting, and
6
+ * redemption are non-goals (§12), so this package has no code path that populates
7
+ * `capabilityGrants` -- and refuses the entry that would need one rather than posting it with the
8
+ * private material silently dropped.
9
+ */
10
+ export declare function assertPublicSpecEvaluationLeg(entry: PostingPoolEntry): void;
11
+ /**
12
+ * Reads `attempts.maxTotal` back out of the bytes that were just sealed and holds it against the
13
+ * terms the escrow was computed from (finding F-C5-1's disposition: "half B seals
14
+ * `attempts.maxTotal` explicitly **and re-parses its own sealed bytes to assert agreement**").
15
+ *
16
+ * Comparing the plan's two in-memory numbers is a tautology. The gate that matters is over the
17
+ * bytes, because the requester backend takes the escrow multiplier from the sealed document
18
+ * (`submission.attempts?.maxTotal ?? 1`): a sealing layer that dropped, renamed, or coerced the
19
+ * field would send `(solution + verdict) x 1` to the chain while the surfaced plan and its log
20
+ * line said N. `assertMaxClaimsAgreement` throws on `undefined` precisely to make that fallback
21
+ * unreachable from here.
22
+ */
23
+ export declare function assertSealedMaxClaimsAgreement(submissionBytes: Uint8Array, termsMaxClaims: number): void;
24
+ export declare function buildDispatchSubmission(entry: PostingPoolEntry, planEntry: PostingPlanEntry, plan: PostingPlan): Uint8Array;
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The dispatch Submission a planned entry posts under. Every field is a function of the plan and
3
+ // the entry -- no clock read, no random identifier -- because the broadcast-intent WAL is keyed on
4
+ // `(creatorSafe, taskCidDigest, submissionDigest)`: a Submission that differed between two runs of
5
+ // one plan would key a second intent and pay for a second post.
6
+ import { ADMISSION_RECEIPT_ANNOTATION_URI, assertMaxClaimsAgreement, } from "@jinn-network/marketplace-binding";
7
+ import { SubmissionRecordSchema, TASK_EXECUTION_PROTOCOL_URI, sealSubmission, sha256Hex, } from "@jinn-network/task-execution-protocol";
8
+ import { PREFIXED_SHA256_PATTERN } from "./digest.js";
9
+ /** Namespace for the identifiers derived below; part of the byte-stability contract. */
10
+ export const POSTING_SUBMISSION_NAMESPACE = "jinn:task-posting:submission:v1";
11
+ /**
12
+ * Record-body digests are `sha256:`-prefixed; in-toto DigestSet values are bare hex (program §5
13
+ * contract 6). This is the only conversion in this package that lands inside a document a
14
+ * requester pays to post, and nothing downstream re-checks it: TEP's `DigestMap` requires only
15
+ * non-empty keys, and the evaluation leg checks only that the admission-receipt descriptor is
16
+ * present and named. An unchecked `.slice()` here would seal `""`, `sha256:aaa...`, `AAA...`, or
17
+ * `zz` into an escrowed task and every later gate would still pass.
18
+ */
19
+ function bareHex(digest, field) {
20
+ if (!PREFIXED_SHA256_PATTERN.test(digest)) {
21
+ throw new Error(`${field} ${JSON.stringify(digest)} is not a sha256:-prefixed lower-case 64-hex digest `
22
+ + "-- refusing to seal it into a Submission that is about to be posted and escrowed");
23
+ }
24
+ return digest.slice("sha256:".length);
25
+ }
26
+ /** 32 hex characters of a digest, shaped as a URN UUID (§8's `submission` identifier form). */
27
+ function urnUuidFromHex(hex) {
28
+ return `urn:uuid:${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`
29
+ + `-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
30
+ }
31
+ /**
32
+ * v1 posts public-specification evaluation legs only (design §8, D5). Grant hosting, minting, and
33
+ * redemption are non-goals (§12), so this package has no code path that populates
34
+ * `capabilityGrants` -- and refuses the entry that would need one rather than posting it with the
35
+ * private material silently dropped.
36
+ */
37
+ export function assertPublicSpecEvaluationLeg(entry) {
38
+ if (entry.evaluationSpecPublic !== true) {
39
+ throw new Error(`${entry.taskDigest} declares a non-public evaluation specification; v1 posts `
40
+ + "public-specification evaluation legs only (design §8, D5)");
41
+ }
42
+ }
43
+ /**
44
+ * Reads `attempts.maxTotal` back out of the bytes that were just sealed and holds it against the
45
+ * terms the escrow was computed from (finding F-C5-1's disposition: "half B seals
46
+ * `attempts.maxTotal` explicitly **and re-parses its own sealed bytes to assert agreement**").
47
+ *
48
+ * Comparing the plan's two in-memory numbers is a tautology. The gate that matters is over the
49
+ * bytes, because the requester backend takes the escrow multiplier from the sealed document
50
+ * (`submission.attempts?.maxTotal ?? 1`): a sealing layer that dropped, renamed, or coerced the
51
+ * field would send `(solution + verdict) x 1` to the chain while the surfaced plan and its log
52
+ * line said N. `assertMaxClaimsAgreement` throws on `undefined` precisely to make that fallback
53
+ * unreachable from here.
54
+ */
55
+ export function assertSealedMaxClaimsAgreement(submissionBytes, termsMaxClaims) {
56
+ const parsed = SubmissionRecordSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(submissionBytes)));
57
+ assertMaxClaimsAgreement(parsed.attempts?.maxTotal, termsMaxClaims);
58
+ }
59
+ export function buildDispatchSubmission(entry, planEntry, plan) {
60
+ assertPublicSpecEvaluationLeg(entry);
61
+ const receiptHex = bareHex(entry.admissionReceiptDigest, "admissionReceiptDigest");
62
+ const taskHex = sha256Hex(entry.taskBytes);
63
+ if (taskHex !== bareHex(entry.taskDigest, "taskDigest")) {
64
+ throw new Error(`pool entry ${entry.taskDigest} does not hash to its own bytes (sha256:${taskHex}) `
65
+ + "-- refusing to seal a Submission against a mismatched pair");
66
+ }
67
+ if (planEntry.maxClaims !== plan.terms.maxClaims) {
68
+ throw new Error(`plan entry maxClaims (${planEntry.maxClaims}) disagrees with the plan's terms `
69
+ + `(${plan.terms.maxClaims})`);
70
+ }
71
+ const seed = sha256Hex(new TextEncoder().encode(`${POSTING_SUBMISSION_NAMESPACE}|${entry.taskDigest}|${plan.requester}|${planEntry.deadline}`
72
+ + `|${planEntry.closeAt ?? ""}|${planEntry.maxClaims}`));
73
+ const submissionBytes = sealSubmission({
74
+ protocol: TASK_EXECUTION_PROTOCOL_URI,
75
+ submission: urnUuidFromHex(seed),
76
+ task: { digest: { sha256: taskHex } },
77
+ requester: plan.requester,
78
+ idempotencyKey: `${POSTING_SUBMISSION_NAMESPACE}:${entry.taskDigest}:${planEntry.deadline}`,
79
+ nonce: seed.slice(0, 32),
80
+ deadline: planEntry.deadline,
81
+ ...(planEntry.closeAt === undefined ? {} : { closeAt: planEntry.closeAt }),
82
+ attempts: { maxTotal: planEntry.maxClaims },
83
+ annotations: {
84
+ [ADMISSION_RECEIPT_ANNOTATION_URI]: {
85
+ name: "admission-receipt",
86
+ digest: { sha256: receiptHex },
87
+ ...(entry.admissionReceiptUri === undefined ? {} : { uri: entry.admissionReceiptUri }),
88
+ },
89
+ },
90
+ });
91
+ assertSealedMaxClaimsAgreement(submissionBytes, plan.terms.maxClaims);
92
+ return submissionBytes;
93
+ }
@@ -0,0 +1,54 @@
1
+ import { type MarketplaceRequesterBackend } from "@jinn-network/marketplace-binding";
2
+ import type { PostingPlan, PostingPoolEntry } from "./types.js";
3
+ /** Shows the operator what is about to be spent. Rendering is the host's job, not this package's. */
4
+ export interface PostingRenderPort {
5
+ renderPlan(plan: PostingPlan): void | Promise<void>;
6
+ }
7
+ export type PostingApproval = {
8
+ readonly approved: true;
9
+ } | {
10
+ readonly approved: false;
11
+ readonly reason: string;
12
+ };
13
+ export interface PostingApprovalPort {
14
+ approvePlan(plan: PostingPlan): Promise<PostingApproval>;
15
+ }
16
+ export interface PostingLogLine {
17
+ readonly event: "posting.plan-surfaced" | "posting.auto-approved" | "posting.approved" | "posting.refused" | "posting.posted" | "posting.uncertain";
18
+ readonly fields: Readonly<Record<string, string>>;
19
+ }
20
+ /** Structured sink; this package never writes to an ambient console. */
21
+ export interface PostingLogPort {
22
+ record(line: PostingLogLine): void;
23
+ }
24
+ export interface PostingDeps {
25
+ /** Task digest -> pool entry. The plan carries digests; the bytes are read here. */
26
+ readonly entries: ReadonlyMap<string, PostingPoolEntry>;
27
+ /**
28
+ * Sole authority for requester scope, posting WAL and recovered outcome reconciliation.
29
+ * The host composes it with the same creator and terms used to construct the plan.
30
+ */
31
+ readonly backend: MarketplaceRequesterBackend;
32
+ readonly render: PostingRenderPort;
33
+ readonly approval: PostingApprovalPort;
34
+ readonly log: PostingLogPort;
35
+ }
36
+ export interface PostingRunSummary {
37
+ readonly posted: readonly {
38
+ readonly taskDigest: `sha256:${string}`;
39
+ readonly taskId: bigint;
40
+ readonly txHash: `0x${string}`;
41
+ }[];
42
+ readonly uncertain: readonly {
43
+ readonly taskDigest: `sha256:${string}`;
44
+ readonly detail: string;
45
+ }[];
46
+ /** Set only when approval was withheld; the batch then spent nothing. */
47
+ readonly refused?: string;
48
+ readonly spentEscrowValueWei: bigint;
49
+ }
50
+ export declare class PostingRefusedError extends Error {
51
+ readonly reason: string;
52
+ constructor(reason: string, message: string);
53
+ }
54
+ export declare function executePosting(deps: PostingDeps, plan: PostingPlan): Promise<PostingRunSummary>;
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Explicit post is the default (design §8): the plan -- terms and the computed escrow total -- is
3
+ // surfaced before a wei is spent, and an approval decision is required. Auto-post is an opt-in
4
+ // standing policy flag that pre-approves with THE SAME fields in a log line, so a standing policy
5
+ // is never quieter than a hand-approved one (visible-money-actions).
6
+ //
7
+ // Everything that touches the chain is injected. The requester backend is the sole posting and
8
+ // recovery authority; this package retains only selection, terms, approval, batching and logging.
9
+ import { BroadcastUncertainError, postingEscrowValueWei, } from "@jinn-network/marketplace-binding";
10
+ import { buildDispatchSubmission } from "./dispatch-submission.js";
11
+ export class PostingRefusedError extends Error {
12
+ reason;
13
+ constructor(reason, message) {
14
+ super(message);
15
+ this.reason = reason;
16
+ this.name = "PostingRefusedError";
17
+ }
18
+ }
19
+ /** The money fields, identical for the explicit and the auto path. */
20
+ function planFields(plan) {
21
+ return {
22
+ createdAt: plan.createdAt,
23
+ creatorSafe: plan.creatorSafe,
24
+ requester: plan.requester,
25
+ entries: String(plan.entries.length),
26
+ totalEscrowValueWei: String(plan.totalEscrowValueWei),
27
+ solutionMaxDeliveryRateWei: String(plan.terms.solutionMaxDeliveryRateWei),
28
+ verdictMaxDeliveryRateWei: String(plan.terms.verdictMaxDeliveryRateWei),
29
+ responseTimeoutSeconds: String(plan.terms.responseTimeoutSeconds),
30
+ maxClaims: String(plan.terms.maxClaims),
31
+ allowSolverSelfEvaluation: String(plan.terms.allowSolverSelfEvaluation),
32
+ };
33
+ }
34
+ /**
35
+ * Everything checkable about the batch, checked before the first wei leaves: the bytes for every
36
+ * planned digest are in hand, every entry's escrow is the escrow the plan's own terms imply, and
37
+ * every dispatch Submission seals. All three are pure, so a refusal raised from inside the post
38
+ * loop would only mean earlier entries were already posted and escrowed when it fired.
39
+ *
40
+ * The escrow re-derivation is the same class of check as the `maxClaims` gate one layer down:
41
+ * the requester backend computes the real `msg.value` from its configured terms and the sealed
42
+ * `attempts.maxTotal`. A plan carrying an understated `escrowValueWei` would render, log, and
43
+ * total a number that is not what the chain is sent.
44
+ */
45
+ function prepareBatch(deps, plan) {
46
+ const expectedEscrowValueWei = postingEscrowValueWei(plan.terms);
47
+ return plan.entries.map((planEntry) => {
48
+ const entry = deps.entries.get(planEntry.taskDigest);
49
+ if (entry === undefined) {
50
+ throw new PostingRefusedError("pool-entry-missing", `planned entry ${planEntry.taskDigest} is not in the supplied pool -- refusing to post a `
51
+ + "batch whose bytes cannot be read");
52
+ }
53
+ if (planEntry.escrowValueWei !== expectedEscrowValueWei) {
54
+ throw new PostingRefusedError("escrow-disagrees-with-terms", `planned entry ${planEntry.taskDigest} carries escrow ${planEntry.escrowValueWei} wei, but `
55
+ + `this plan's terms imply ${expectedEscrowValueWei} wei -- the surfaced total would not `
56
+ + "be what is sent");
57
+ }
58
+ return { planEntry, entry, submissionBytes: buildDispatchSubmission(entry, planEntry, plan) };
59
+ });
60
+ }
61
+ function uncertainOperationDetail(error) {
62
+ if (error instanceof BroadcastUncertainError)
63
+ return error.message;
64
+ // MarketplaceRequesterBackend exposes operational failures through TaskExecutionBackend's
65
+ // error contract. Avoid a second runtime package edge just to recognize the generic shape.
66
+ if (error instanceof Error
67
+ && error.name === "TaskExecutionError"
68
+ && "category" in error
69
+ && error.category === "backend-unavailable"
70
+ && "retryable" in error
71
+ && error.retryable === true) {
72
+ const detail = "detail" in error ? error.detail : undefined;
73
+ return typeof detail === "string" ? detail : error.message;
74
+ }
75
+ return undefined;
76
+ }
77
+ export async function executePosting(deps, plan) {
78
+ await deps.render.renderPlan(plan);
79
+ deps.log.record({ event: "posting.plan-surfaced", fields: planFields(plan) });
80
+ if (plan.approval === "auto") {
81
+ deps.log.record({ event: "posting.auto-approved", fields: planFields(plan) });
82
+ }
83
+ else {
84
+ const decision = await deps.approval.approvePlan(plan);
85
+ if (!decision.approved) {
86
+ deps.log.record({
87
+ event: "posting.refused",
88
+ fields: { ...planFields(plan), reason: decision.reason },
89
+ });
90
+ return { posted: [], uncertain: [], refused: decision.reason, spentEscrowValueWei: 0n };
91
+ }
92
+ deps.log.record({ event: "posting.approved", fields: planFields(plan) });
93
+ }
94
+ // After the approval gate (a withheld plan is never inspected further) and before the first
95
+ // post: the whole batch is resolved and sealed, so a refusal cannot land mid-spend.
96
+ const prepared = prepareBatch(deps, plan);
97
+ const posted = [];
98
+ const uncertain = [];
99
+ let spentEscrowValueWei = 0n;
100
+ for (const { planEntry, entry, submissionBytes } of prepared) {
101
+ let outcome;
102
+ try {
103
+ // eslint-disable-next-line no-await-in-loop -- posts are sequential: one requester, one
104
+ // nonce sequence, and one escrow balance draining as the batch proceeds.
105
+ outcome = await deps.backend.post(entry.taskBytes, submissionBytes);
106
+ }
107
+ catch (error) {
108
+ const detail = uncertainOperationDetail(error);
109
+ if (detail !== undefined) {
110
+ // Never retried here: the requester backend resolves uncertain work through
111
+ // `recoverPosting()` and canonical evidence, not by posting again.
112
+ uncertain.push({ taskDigest: planEntry.taskDigest, detail });
113
+ deps.log.record({
114
+ event: "posting.uncertain",
115
+ fields: { taskDigest: planEntry.taskDigest, detail },
116
+ });
117
+ continue;
118
+ }
119
+ throw error;
120
+ }
121
+ posted.push({ taskDigest: planEntry.taskDigest, taskId: outcome.taskId, txHash: outcome.txHash });
122
+ spentEscrowValueWei += planEntry.escrowValueWei;
123
+ deps.log.record({
124
+ event: "posting.posted",
125
+ fields: {
126
+ taskDigest: planEntry.taskDigest,
127
+ taskId: String(outcome.taskId),
128
+ txHash: outcome.txHash,
129
+ escrowValueWei: String(planEntry.escrowValueWei),
130
+ },
131
+ });
132
+ }
133
+ return { posted, uncertain, spentEscrowValueWei };
134
+ }
@@ -0,0 +1,7 @@
1
+ export { planPosting } from "./plan.js";
2
+ export type { PostingPlan, PostingPlanEntry, PostingPolicy, PostingPoolEntry, PostingSkip, PostingSkipReason, } from "./types.js";
3
+ export { POSTING_SUBMISSION_NAMESPACE, buildDispatchSubmission } from "./dispatch-submission.js";
4
+ export { evaluationSpecIsPublic, postingPoolEntry } from "./pool-entry.js";
5
+ export type { PostingPoolEntryOptions, SuppliedPoolEntry } from "./pool-entry.js";
6
+ export { PostingRefusedError, executePosting } from "./execute.js";
7
+ export type { PostingApproval, PostingApprovalPort, PostingDeps, PostingLogLine, PostingLogPort, PostingRenderPort, PostingRunSummary, } from "./execute.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // @jinn-network/task-posting -- public surface.
2
+ //
3
+ // Policy in, posts out. `PostingDeps.backend` is the sole requester posting/recovery authority;
4
+ // this package does not accept raw chain or binding ports.
5
+ export { planPosting } from "./plan.js";
6
+ export { POSTING_SUBMISSION_NAMESPACE, buildDispatchSubmission } from "./dispatch-submission.js";
7
+ // The pool-shape reconciliation the program still has to rule on (README, F-C5-8): one named
8
+ // adapter from what the supply pool stores to what this application posts, with D5's publicness
9
+ // read off the sealed specification bytes instead of taken as a caller's claim.
10
+ export { evaluationSpecIsPublic, postingPoolEntry } from "./pool-entry.js";
11
+ export { PostingRefusedError, executePosting } from "./execute.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * UTF-16 code-unit ordering. `String.prototype.localeCompare` reads the host locale and the
3
+ * bundled ICU data, so it must never decide a batch's order: two hosts would plan two different
4
+ * batches from the same pool.
5
+ */
6
+ export declare function compareCodeUnitStrings(left: string, right: string): number;
package/dist/order.js ADDED
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * UTF-16 code-unit ordering. `String.prototype.localeCompare` reads the host locale and the
4
+ * bundled ICU data, so it must never decide a batch's order: two hosts would plan two different
5
+ * batches from the same pool.
6
+ */
7
+ export function compareCodeUnitStrings(left, right) {
8
+ if (left < right)
9
+ return -1;
10
+ if (left > right)
11
+ return 1;
12
+ return 0;
13
+ }
package/dist/plan.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { PostingPlan, PostingPolicy, PostingPoolEntry } from "./types.js";
2
+ /**
3
+ * Selects and orders the entries this batch posts.
4
+ *
5
+ * @param pool the materialized pool listing (finding F-C5-2: the caller reads `SupplyPool.list()`
6
+ * and passes the result; a pure function cannot await a store).
7
+ */
8
+ export declare function planPosting(pool: readonly PostingPoolEntry[], policy: PostingPolicy): PostingPlan;
package/dist/plan.js ADDED
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Supply policy, and nothing else (design §8, D6: production never posts; posting never derives).
3
+ // Pure by construction: the plan is the replay unit, so every value the sealed Submission depends
4
+ // on -- deadline, closeAt, maxClaims, requester -- is decided here and carried, never read from a
5
+ // host clock at execution time.
6
+ import { postingEscrowValueWei } from "@jinn-network/marketplace-binding";
7
+ import { PREFIXED_SHA256_PATTERN } from "./digest.js";
8
+ import { compareCodeUnitStrings } from "./order.js";
9
+ function offsetIso(base, seconds) {
10
+ const parsed = Date.parse(base);
11
+ if (Number.isNaN(parsed)) {
12
+ throw new Error(`policy.now must be an RFC 3339 timestamp with an offset, got ${base}`);
13
+ }
14
+ return new Date(parsed + seconds * 1_000).toISOString();
15
+ }
16
+ /**
17
+ * Both policy digest lists are membership-tested by exact string equality against `sha256:`-
18
+ * prefixed entry digests, so a list written in any other form matches nothing and the guard it was
19
+ * written for silently disarms: an excluded entry posts and its escrow is spent, and an
20
+ * already-posted entry is posted and escrowed a second time. Bare hex is the realistic mistake —
21
+ * it is the in-toto DigestSet form this stack writes elsewhere by law (program §5 contract 6) — so
22
+ * the list is validated rather than trusted, and a non-canonical entry fails the plan closed
23
+ * instead of quietly dropping out of the comparison.
24
+ */
25
+ function digestSet(values, field) {
26
+ const digests = new Set();
27
+ for (const value of values ?? []) {
28
+ if (!PREFIXED_SHA256_PATTERN.test(value)) {
29
+ throw new Error(`policy.${field} entry ${JSON.stringify(value)} is not a sha256:-prefixed lower-case `
30
+ + "64-hex digest -- it would match no pool entry, so the batch would post and escrow the "
31
+ + "entry this list was written to hold back");
32
+ }
33
+ digests.add(value);
34
+ }
35
+ return digests;
36
+ }
37
+ /**
38
+ * Selects and orders the entries this batch posts.
39
+ *
40
+ * @param pool the materialized pool listing (finding F-C5-2: the caller reads `SupplyPool.list()`
41
+ * and passes the result; a pure function cannot await a store).
42
+ */
43
+ export function planPosting(pool, policy) {
44
+ if (!Number.isInteger(policy.batchLimit) || policy.batchLimit < 1) {
45
+ throw new RangeError(`batchLimit must be a positive integer, got ${String(policy.batchLimit)}`);
46
+ }
47
+ if (!Number.isInteger(policy.deadlineSeconds) || policy.deadlineSeconds < 1) {
48
+ throw new RangeError(`deadlineSeconds must be a positive integer, got ${String(policy.deadlineSeconds)}`);
49
+ }
50
+ const deadline = offsetIso(policy.now, policy.deadlineSeconds);
51
+ const closeAt = policy.closeAtSeconds === undefined
52
+ ? undefined
53
+ : offsetIso(policy.now, policy.closeAtSeconds);
54
+ const excluded = digestSet(policy.excludedTaskDigests, "excludedTaskDigests");
55
+ const posted = digestSet(policy.postedTaskDigests, "postedTaskDigests");
56
+ const skipped = [];
57
+ const eligible = [];
58
+ for (const entry of pool) {
59
+ if (excluded.has(entry.taskDigest)) {
60
+ skipped.push({ taskDigest: entry.taskDigest, reason: "excluded" });
61
+ continue;
62
+ }
63
+ if (!entry.evaluationSpecPublic) {
64
+ skipped.push({ taskDigest: entry.taskDigest, reason: "evaluation-not-public" });
65
+ continue;
66
+ }
67
+ const repost = posted.has(entry.taskDigest);
68
+ if (repost && policy.repostPosted !== true) {
69
+ skipped.push({ taskDigest: entry.taskDigest, reason: "already-posted" });
70
+ continue;
71
+ }
72
+ eligible.push({ entry, repost });
73
+ }
74
+ // Never-posted first; within a group, code-unit order by digest so two hosts plan one batch.
75
+ eligible.sort((left, right) => {
76
+ if (left.repost !== right.repost)
77
+ return left.repost ? 1 : -1;
78
+ return compareCodeUnitStrings(left.entry.taskDigest, right.entry.taskDigest);
79
+ });
80
+ const entries = [];
81
+ for (const candidate of eligible) {
82
+ if (entries.length >= policy.batchLimit) {
83
+ skipped.push({ taskDigest: candidate.entry.taskDigest, reason: "batch-limit" });
84
+ continue;
85
+ }
86
+ entries.push({
87
+ taskDigest: candidate.entry.taskDigest,
88
+ deadline,
89
+ ...(closeAt === undefined ? {} : { closeAt }),
90
+ maxClaims: policy.terms.maxClaims,
91
+ escrowValueWei: postingEscrowValueWei(policy.terms),
92
+ repost: candidate.repost,
93
+ });
94
+ }
95
+ return {
96
+ createdAt: policy.now,
97
+ creatorSafe: policy.creatorSafe,
98
+ requester: policy.requester,
99
+ terms: policy.terms,
100
+ approval: policy.autoPost === true ? "auto" : "explicit",
101
+ entries,
102
+ totalEscrowValueWei: entries.reduce((total, planned) => total + planned.escrowValueWei, 0n),
103
+ skipped,
104
+ };
105
+ }
@@ -0,0 +1,34 @@
1
+ import type { PostingPoolEntry } from "./types.js";
2
+ /**
3
+ * A pool entry as the supply pool stores it: the sealed pair's bytes, the digests that address
4
+ * them, and the digest of the admission receipt the pair earned.
5
+ */
6
+ export interface SuppliedPoolEntry {
7
+ readonly taskDigest: `sha256:${string}`;
8
+ readonly taskBytes: Uint8Array;
9
+ readonly evaluationSpecDigest: `sha256:${string}`;
10
+ readonly evaluationSpecBytes: Uint8Array;
11
+ /** C4's name for the admission receipt this pair earned. */
12
+ readonly receiptDigest: `sha256:${string}`;
13
+ }
14
+ export interface PostingPoolEntryOptions {
15
+ /** Advisory acquisition hint for the receipt; the digest is what binds. */
16
+ readonly admissionReceiptUri?: string;
17
+ }
18
+ /**
19
+ * Whether every access-classified descriptor in a sealed EvaluationSpec is stamped public.
20
+ *
21
+ * A specification with no stamps at all is `false`: absence of a stamp is not evidence of
22
+ * publicness, and v1 posts public-specification evaluation legs only (design §8, D5). The walk is
23
+ * family-agnostic on purpose — it reads the stamps wherever a family block puts them rather than
24
+ * hard-coding one family's shape into the posting application.
25
+ */
26
+ export declare function evaluationSpecIsPublic(evaluationSpecBytes: Uint8Array): boolean;
27
+ /**
28
+ * Builds the entry this application posts from the entry the pool stores.
29
+ *
30
+ * Both digests are checked against the bytes they address before anything is read from those
31
+ * bytes: a publicness stamp read out of a specification the entry does not name would be a claim
32
+ * about some other document.
33
+ */
34
+ export declare function postingPoolEntry(entry: SuppliedPoolEntry, options?: PostingPoolEntryOptions): PostingPoolEntry;
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The reconciliation between what C4's pool stores and what this application posts, filed as
3
+ // F-C5-8 (see README, "Reported to the program"). C4's `PoolEntry` names the admission receipt
4
+ // `receiptDigest` and models no publicness flag at all, while spec §8's D5 gate — the one
5
+ // `planPosting` and `buildDispatchSubmission` both enforce — needs `admissionReceiptDigest` and
6
+ // `evaluationSpecPublic`. Without this adapter the tier-4 composition would have to invent both,
7
+ // and the D5 refusal would rest on a caller's assertion rather than on the sealed bytes.
8
+ //
9
+ // So `evaluationSpecPublic` is read here, off the EvaluationSpec bytes the entry's own digest
10
+ // addresses: D5's stamping puts `accessClass: "public"` on every access-classified descriptor at
11
+ // seal time, and this reads those stamps back. Fail closed in every direction — an unstamped
12
+ // specification, a private stamp, a non-string stamp, or bytes that are not a JSON document all
13
+ // yield `false`, which `planPosting` records as the `evaluation-not-public` skip.
14
+ //
15
+ // No runtime edge into `@jinn-network/task-derivation` (finding F-C5-5): the input is declared
16
+ // structurally here and `pool-shape.test.ts` pins C4's stored entry against it at compile time.
17
+ import { sha256Hex } from "@jinn-network/task-execution-protocol";
18
+ import { assertPrefixedSha256 } from "./digest.js";
19
+ const ACCESS_CLASS_KEY = "accessClass";
20
+ const PUBLIC_ACCESS_CLASS = "public";
21
+ function collectAccessClasses(node, found) {
22
+ if (Array.isArray(node)) {
23
+ for (const item of node)
24
+ collectAccessClasses(item, found);
25
+ return;
26
+ }
27
+ if (node === null || typeof node !== "object")
28
+ return;
29
+ for (const [key, value] of Object.entries(node)) {
30
+ if (key === ACCESS_CLASS_KEY) {
31
+ found.push(value);
32
+ continue;
33
+ }
34
+ collectAccessClasses(value, found);
35
+ }
36
+ }
37
+ /**
38
+ * Whether every access-classified descriptor in a sealed EvaluationSpec is stamped public.
39
+ *
40
+ * A specification with no stamps at all is `false`: absence of a stamp is not evidence of
41
+ * publicness, and v1 posts public-specification evaluation legs only (design §8, D5). The walk is
42
+ * family-agnostic on purpose — it reads the stamps wherever a family block puts them rather than
43
+ * hard-coding one family's shape into the posting application.
44
+ */
45
+ export function evaluationSpecIsPublic(evaluationSpecBytes) {
46
+ let document;
47
+ try {
48
+ document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(evaluationSpecBytes));
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ const found = [];
54
+ collectAccessClasses(document, found);
55
+ if (found.length === 0)
56
+ return false;
57
+ return found.every((value) => value === PUBLIC_ACCESS_CLASS);
58
+ }
59
+ /**
60
+ * Builds the entry this application posts from the entry the pool stores.
61
+ *
62
+ * Both digests are checked against the bytes they address before anything is read from those
63
+ * bytes: a publicness stamp read out of a specification the entry does not name would be a claim
64
+ * about some other document.
65
+ */
66
+ export function postingPoolEntry(entry, options = {}) {
67
+ assertPrefixedSha256(entry.taskDigest, "taskDigest");
68
+ assertPrefixedSha256(entry.evaluationSpecDigest, "evaluationSpecDigest");
69
+ assertPrefixedSha256(entry.receiptDigest, "receiptDigest");
70
+ const taskHex = sha256Hex(entry.taskBytes);
71
+ if (taskHex !== entry.taskDigest.slice("sha256:".length)) {
72
+ throw new Error(`pool entry taskDigest ${entry.taskDigest} does not address its bytes (sha256:${taskHex})`);
73
+ }
74
+ const specHex = sha256Hex(entry.evaluationSpecBytes);
75
+ if (specHex !== entry.evaluationSpecDigest.slice("sha256:".length)) {
76
+ throw new Error(`pool entry evaluationSpecDigest ${entry.evaluationSpecDigest} does not address its bytes `
77
+ + `(sha256:${specHex}) -- the access class read from those bytes would describe another `
78
+ + "specification");
79
+ }
80
+ return {
81
+ taskDigest: entry.taskDigest,
82
+ taskBytes: entry.taskBytes,
83
+ evaluationSpecDigest: entry.evaluationSpecDigest,
84
+ admissionReceiptDigest: entry.receiptDigest,
85
+ ...(options.admissionReceiptUri === undefined
86
+ ? {}
87
+ : { admissionReceiptUri: options.admissionReceiptUri }),
88
+ evaluationSpecPublic: evaluationSpecIsPublic(entry.evaluationSpecBytes),
89
+ };
90
+ }
@@ -0,0 +1,66 @@
1
+ import type { DefaultPostingTerms } from "@jinn-network/marketplace-binding";
2
+ /**
3
+ * One admitted, sealed pair as this application needs it. Structurally what C4's `SupplyPool`
4
+ * listing yields; declared here because §3.1 makes this package usable by "any requester with
5
+ * sealed pairs from any source", not only by C4's pool.
6
+ *
7
+ * `admissionReceiptDigest` is required: the dispatch Submission must carry the admission-receipt
8
+ * descriptor, or the evaluation leg derived from it later is refused (binding §7.39; plan finding
9
+ * F-C5-4).
10
+ */
11
+ export interface PostingPoolEntry {
12
+ readonly taskDigest: `sha256:${string}`;
13
+ readonly taskBytes: Uint8Array;
14
+ readonly evaluationSpecDigest: `sha256:${string}`;
15
+ readonly admissionReceiptDigest: `sha256:${string}`;
16
+ readonly admissionReceiptUri?: string;
17
+ /** v1 posts public-specification evaluation legs only (design §8, D5); false is refused. */
18
+ readonly evaluationSpecPublic: boolean;
19
+ }
20
+ export type PostingSkipReason = "excluded" | "already-posted" | "batch-limit" | "evaluation-not-public";
21
+ export interface PostingSkip {
22
+ readonly taskDigest: `sha256:${string}`;
23
+ readonly reason: PostingSkipReason;
24
+ }
25
+ /**
26
+ * Supply policy. `now` is an input, not a clock read: the sealed Submission is a function of the
27
+ * plan, so a plan built twice from the same inputs yields byte-identical bytes and therefore the
28
+ * same broadcast-intent key. A hidden clock would turn every replay into a new post.
29
+ */
30
+ export interface PostingPolicy {
31
+ readonly terms: DefaultPostingTerms;
32
+ readonly creatorSafe: `0x${string}`;
33
+ /** The requester of record recorded in every Submission this plan seals. */
34
+ readonly requester: string;
35
+ /** RFC 3339 with an explicit offset. */
36
+ readonly now: string;
37
+ readonly deadlineSeconds: number;
38
+ readonly closeAtSeconds?: number;
39
+ readonly batchLimit: number;
40
+ readonly excludedTaskDigests?: readonly string[];
41
+ /** Digests already posted by this requester; never-posted entries always plan first. */
42
+ readonly postedTaskDigests?: readonly string[];
43
+ /** Default false: an already-posted entry is dropped, not merely deprioritized. */
44
+ readonly repostPosted?: boolean;
45
+ /** Opt-in standing approval. The plan still renders and still logs its terms and escrow. */
46
+ readonly autoPost?: boolean;
47
+ }
48
+ export interface PostingPlanEntry {
49
+ readonly taskDigest: `sha256:${string}`;
50
+ readonly deadline: string;
51
+ readonly closeAt?: string;
52
+ readonly maxClaims: number;
53
+ readonly escrowValueWei: bigint;
54
+ /** True when this digest appears in `postedTaskDigests` and reposting was allowed. */
55
+ readonly repost: boolean;
56
+ }
57
+ export interface PostingPlan {
58
+ readonly createdAt: string;
59
+ readonly creatorSafe: `0x${string}`;
60
+ readonly requester: string;
61
+ readonly terms: DefaultPostingTerms;
62
+ readonly approval: "explicit" | "auto";
63
+ readonly entries: readonly PostingPlanEntry[];
64
+ readonly totalEscrowValueWei: bigint;
65
+ readonly skipped: readonly PostingSkip[];
66
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export {};
package/package.json CHANGED
@@ -1,13 +1,53 @@
1
1
  {
2
2
  "name": "@jinn-network/task-posting",
3
- "version": "0.0.0",
4
- "description": "Name reservation for npm trusted-publisher setup. Not a platform receipt.",
5
- "license": "Apache-2.0",
3
+ "version": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
4
+ "description": "Supply policy for marketplace posting: which admitted, sealed task pairs post, when, at what terms, with the escrow surfaced before spending.",
5
+ "type": "module",
6
+ "packageManager": "yarn@4.13.0",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "license": "MIT",
6
11
  "repository": {
7
12
  "type": "git",
8
- "url": "https://github.com/Jinn-Network/mono.git"
13
+ "url": "https://github.com/Jinn-Network/mono.git",
14
+ "directory": "packages/task-supply/posting"
15
+ },
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./dist/index.js",
21
+ "types": "./dist/index.d.ts"
22
+ }
9
23
  },
24
+ "files": [
25
+ "dist/",
26
+ "README.md"
27
+ ],
10
28
  "publishConfig": {
11
29
  "access": "public"
12
- }
30
+ },
31
+ "scripts": {
32
+ "build": "node scripts/build.mjs",
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "test": "vitest run",
35
+ "pack:smoke": "node scripts/pack-smoke.mjs",
36
+ "prepack": "yarn build"
37
+ },
38
+ "dependencies": {
39
+ "@jinn-network/marketplace-binding": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
40
+ "@jinn-network/task-execution-protocol": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b"
41
+ },
42
+ "devDependencies": {
43
+ "@jinn-network/task-derivation": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
44
+ "@jinn-network/task-execution-backend": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
45
+ "@jinn-network/task-execution-profiles": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
46
+ "@jinn-network/trust-core": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
47
+ "@jinn-network/trust-resolve": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
48
+ "@types/node": "^22.0.0",
49
+ "typescript": "^5.9.3",
50
+ "vitest": "^4.1.8"
51
+ },
52
+ "gitHead": "786221faf848b3c859ddbc76fec09338df64d83b"
13
53
  }