diff --git a/docs/design.md b/docs/design.md index 5c05c92..0b5cdb5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -61,9 +61,14 @@ com.disnetdev.radial.join {space} com.disnetdev.radial.project {space, name, gitUrl, defaultBranch, checks: [{name, command}], — machine-verifiable autoReview: {: bool}} — per-type trigger default (§7) -com.disnetdev.radial.goal {space, project, title, body, closed?} — author = any member; `closed` is - LEGACY: read forever, never written - (superseded by closeGoal, §7) +com.disnetdev.radial.goal {space, project, title, body, closed?, — author = any member; `closed` is + source?: {kind, uri, cid?}} LEGACY: read forever, never written + (superseded by closeGoal, §7). + `source` records where an IMPORTED + goal's text was read from (a forge + issue, §13) — an assertion by the + author, never followed, and never + in a bundle com.disnetdev.radial.closeGoal {goal, closed, — a goal's one ending; author = any disposition?, createdAt} active member, reversible (§7) com.disnetdev.radial.artifactType {space, name, brief, outputSpec, @@ -301,6 +306,19 @@ cannot claim this value, and a later reroute cannot rewrite an already-signed re Guest comments (§4) do not widen this, and the reason is structural rather than a matter of framing. An unblessed comment from a non-member is not in any store, any index or any bundle — nothing polls a non-member's repo — so it reaches an agent by no path at all. What can reach one is a member's `blessComment`, and by then the text is *bytes a member signed*: the worst case is exactly the worst case for a hostile member message, which this section already bounds, and it is reversible by `retractBless` from either the blessing's author or an admin. What the UI owes a member is that the choice is informed — the confirm shows the full text being copied — and what `bundle.md` owes an agent is provenance: the section is separate from the thread, labelled with both DIDs, and never merged into it, so a turn can always tell which lines came from inside the space. The label is not the mitigation. The mitigations are that the copy is deliberate and the bound is unchanged. + Importing a **forge issue** as a goal (§4, `goal.source`) does not widen it either, and by the same + structure. Nothing polls a forge's issue records — they are not in any store, index or bundle, and + ingestion has no path to them — so an issue reaches an agent only as a `goal` a member wrote: the + text is bytes that member read on screen, edited if they chose to, and signed, which is the case + this section already bounds. Two properties keep it there. The goal is written through the ordinary + composer rather than by a one-press import, so the words are in front of a human, in an editable + field, with the sentence saying what creating it means. And `source` — the pointer back to the issue + — is deliberately **not** in the turn bundle: it exists so the space can tell which issues it has + already taken up, and an agent handed the at-uri could fetch the live issue and read comments and + edits nobody reviewed. Discovery is view-time and opt-in for the same reason guest-comment discovery + is: the appview that lists issues is a third party, its answer is not part of the fold, and two + materializers agree whether or not it answered. + ## 14. Alternatives considered **A. Fixed lifecycle state machine** (the previous version of this design): `draft → planning → plan_review → implementing → change_review → done`, with per-gate quorum policy, agent prepass, per-phase claims, and goal state derived by an authority-checked fold. Rejected in favor of the artifact model. The machinery existed almost entirely to let agents advance goals *autonomously between human gates* — and that autonomy was the source of every hard problem in the design: claim races and lease heartbeats, ping-pong iteration bounds, budget anxiety, and a materialization fold with state rules and transition-authority checks. Human-initiated requests deliver a stronger human-in-the-loop property (a human decision precedes every step, not just the gated ones) with a fraction of the machinery, and the automation can return incrementally as opt-in trigger rules layered on the artifact substrate rather than as the substrate itself. The extensibility clincher: the fixed graph couldn't grow a "security review" step without lexicon and state-machine changes; in the artifact model it's a registry record and a button. diff --git a/packages/core/src/generated/records.ts b/packages/core/src/generated/records.ts index cccb8f3..9174b9a 100644 --- a/packages/core/src/generated/records.ts +++ b/packages/core/src/generated/records.ts @@ -189,6 +189,8 @@ export interface EditProjectRecord { createdAt: string } +export type GoalSource = { kind: string; uri: string; cid?: string } + export interface GoalRecord { $type: "com.disnetdev.radial.goal" space: StrongRef @@ -197,6 +199,7 @@ export interface GoalRecord { body: string origin?: StrongRef closed?: boolean + source?: GoalSource createdAt: string } @@ -1121,6 +1124,28 @@ export const lexiconSchemas = [ "lexicon": 1, "id": "com.disnetdev.radial.goal", "defs": { + "source": { + "type": "object", + "required": [ + "kind", + "uri" + ], + "properties": { + "kind": { + "type": "string", + "maxLength": 64 + }, + "uri": { + "type": "string", + "format": "uri", + "maxLength": 2000 + }, + "cid": { + "type": "string", + "maxLength": 256 + } + } + }, "main": { "type": "record", "key": "tid", @@ -1157,6 +1182,10 @@ export const lexiconSchemas = [ "closed": { "type": "boolean" }, + "source": { + "type": "ref", + "ref": "#source" + }, "createdAt": { "type": "string", "format": "datetime" diff --git a/packages/core/src/records.ts b/packages/core/src/records.ts index 4ee66d6..2dcf3bb 100644 --- a/packages/core/src/records.ts +++ b/packages/core/src/records.ts @@ -56,4 +56,15 @@ export function agentTypesFor(record: AgentRecord, spaceUri: string): string[] { return [...(scope ? scope.artifactTypes : record.artifactTypes)] } +/** + * The collection a tangled issue is a record in, and the `goal.source.kind` that names one. + * + * A goal imported from an issue carries the pointer so the space can tell which issues it has already + * taken up; it never carries the issue's live text, and nothing mechanical follows the pointer. The + * kind is a free-form string in the lexicon, like `closeGoal.disposition`, so a newer writer can name + * a forge this build has never heard of without older readers rejecting the record. + */ +export const TANGLED_ISSUE_COLLECTION = 'sh.tangled.repo.issue' +export const GOAL_SOURCE_TANGLED_ISSUE = 'tangled-issue' + export * from './generated/records.js' diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index bd0fb76..d211437 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -4,6 +4,7 @@ import { type Collection, type RadialRecord, } from './generated/records.js' +import { GOAL_SOURCE_TANGLED_ISSUE, TANGLED_ISSUE_COLLECTION } from './records.js' export interface ValidationIssue { path: string @@ -338,6 +339,18 @@ function validateInvariants(collection: Collection, value: Record { assert.equal(bundle.project.gitUrl, 'https://example.com/demo.git') }) + it('carries no trace of the issue an imported goal was written from', () => { + // The whole prompt-injection argument for importing a tangled issue rests on this: a human reads + // the issue, signs a goal carrying the text they vouched for, and the pointer to the live issue + // stays behind. An agent handed the at-uri could fetch the issue itself and read comments and + // edits nobody reviewed — so the goal envelope is title and body, and `source` is not in it. + // Asserted over the whole serialized bundle rather than over `bundle.goal`, because bundle.md and + // bundle.json are both rendered from this object: what is not here cannot reach a container. + const scenario = planBundleScenario() + const issue = 'at://did:plc:stranger/sh.tangled.repo.issue/3mrxi5lzk2v22' + const records = scenario.records.map((record) => + record.collection === COLLECTIONS.goal + ? { + ...record, + value: { ...record.value, source: { kind: 'tangled-issue', uri: issue, cid: 'cid-issue' } }, + } + : record, + ) + const bundle = buildBundle(records, scenario) + + assert.equal(bundle.goal.title, 'Ship it') + assert.equal(bundle.goal.body, 'Do the thing.') + assert.equal(bundle.goal.source, undefined) + assert.equal(JSON.stringify(bundle).includes('sh.tangled.repo.issue'), false) + assert.equal(JSON.stringify(bundle).includes(issue), false) + }) + describe('guest comments', () => { const GUEST = 'did:plc:guest' const comment = (rkey, cid) => ({ diff --git a/packages/core/test/validation.test.mjs b/packages/core/test/validation.test.mjs index 2493783..905af4e 100644 --- a/packages/core/test/validation.test.mjs +++ b/packages/core/test/validation.test.mjs @@ -5,6 +5,7 @@ import { resolve } from 'node:path' import { COLLECTIONS } from '../dist/generated/records.js' import { normalizeArtifactTitle } from '../dist/turn-protocol.js' import { validateRecord } from '../dist/validation.js' +import { GOAL_SOURCE_TANGLED_ISSUE } from '../dist/records.js' const fixtures = resolve(import.meta.dirname, 'fixtures') @@ -142,3 +143,40 @@ it('normalizes an artifact title the same way for every writer', () => { assert.equal(normalizeArtifactTitle(undefined), undefined) assert.equal(normalizeArtifactTitle(7), undefined) }) + +it('keeps a goal valid with and without a source, and pins the one kind this build mints', () => { + // `source` is where an imported goal says its text came from. Optional on the wire like every + // other addition (a goal signed before the field existed cannot be rewritten), and the kind is + // free-form so a newer writer can name a forge this build has never heard of. What is checked is + // the kind this build DOES mint: `tangled-issue` promises a record pointer, and a candidate list + // that matched on anything else would offer the same issue for import forever. + const goal = (source) => ({ + $type: COLLECTIONS.goal, + space: { uri: 'at://did:plc:root/com.disnetdev.radial.space/s1', cid: 'c' }, + project: { uri: 'at://did:plc:root/com.disnetdev.radial.project/p1', cid: 'c' }, + title: 'repo operations: archive repository', + body: 'Freeze a repository without deleting it.', + createdAt: '2026-07-31T00:00:00.000Z', + ...(source === undefined ? {} : { source }), + }) + const issue = 'at://did:plc:stranger/sh.tangled.repo.issue/3mrxi5lzk2v22' + assert.equal(validateRecord(COLLECTIONS.goal, goal()).success, true) + assert.equal( + validateRecord(COLLECTIONS.goal, goal({ kind: GOAL_SOURCE_TANGLED_ISSUE, uri: issue, cid: 'bafy' })).success, + true, + ) + assert.equal(validateRecord(COLLECTIONS.goal, goal({ kind: GOAL_SOURCE_TANGLED_ISSUE, uri: issue })).success, true) + // A kind this build does not know is somebody else's forge, not an error: it is carried, and the + // candidate list simply does not match on it. + assert.equal( + validateRecord(COLLECTIONS.goal, goal({ kind: 'github-issue', uri: 'https://github.com/acme/w/issues/7' })).success, + true, + ) + assert.equal( + validateRecord(COLLECTIONS.goal, goal({ kind: GOAL_SOURCE_TANGLED_ISSUE, uri: 'at://did:plc:x/sh.tangled.repo.pull/1' })).success, + false, + ) + assert.equal(validateRecord(COLLECTIONS.goal, goal({ uri: issue })).success, false) + assert.equal(validateRecord(COLLECTIONS.goal, goal({ kind: GOAL_SOURCE_TANGLED_ISSUE })).success, false) + assert.equal(validateRecord(COLLECTIONS.goal, goal({ kind: GOAL_SOURCE_TANGLED_ISSUE, uri: 'not a uri' })).success, false) +}) diff --git a/packages/lexicons/README.md b/packages/lexicons/README.md index 655dc70..421ccde 100644 --- a/packages/lexicons/README.md +++ b/packages/lexicons/README.md @@ -276,3 +276,33 @@ collections or a `join`. Without that, gate 2 is optional — a record with no s device to gate — and anything in a member's public repo folds on membership alone, which is itself just another record in somebody's repo. The space record is the single exemption, because it is what declares the mode. + +## `goal.source` says where imported text came from, and nothing follows it + +`com.disnetdev.radial.goal` carries an optional `source` — `{kind, uri, cid?}` — +for a goal somebody wrote by importing an issue from the project's forge (design +§4, §13; the UI path is `packages/ui/src/lib/issues.ts`). Additive and optional, +on the same terms as `artifact.title`: a goal signed before the field existed +cannot be rewritten, so it must never become required. + +`kind` is free-form, like `closeGoal.disposition` and for the same reason — a +newer writer must be able to name a forge this build has never heard of without +older readers rejecting the record. One value is currently minted, +`tangled-issue`, and that one is checked: its `uri` must name an +`sh.tangled.repo.issue` record, and `cid` pins the version whose text was read. + +Three things a second implementation should know about it: + +- **It is an assertion, not a proof.** The title and body are what a member read, + edited if they chose to, and signed; the writer does not re-fetch the issue and + copy its bytes, which is exactly what `comment bless` *does* do, because a + blessing quotes somebody verbatim and a goal is the member's own words. +- **Nothing mechanical follows it.** It has one consumer: a list of a + repository's open issues hides the ones a goal already names, so two members do + not import the same issue twice. No fold rule reads it. +- **It is not in the turn bundle.** `buildTurnBundle` carries a goal's `uri`, + `cid`, `title` and `body` and no other field, and `core/test/bundle.test.mjs` + asserts that a goal carrying a `source` produces a bundle with no trace of the + issue in it. An agent handed the at-uri could fetch the live issue and read + comments and edits nobody reviewed, which is the thing the import gate exists + to prevent. diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.goal.json b/packages/lexicons/lexicons/com.disnetdev.radial.goal.json index cdc1146..ca7df8c 100644 --- a/packages/lexicons/lexicons/com.disnetdev.radial.goal.json +++ b/packages/lexicons/lexicons/com.disnetdev.radial.goal.json @@ -2,6 +2,15 @@ "lexicon": 1, "id": "com.disnetdev.radial.goal", "defs": { + "source": { + "type": "object", + "required": ["kind", "uri"], + "properties": { + "kind": {"type": "string", "maxLength": 64}, + "uri": {"type": "string", "format": "uri", "maxLength": 2000}, + "cid": {"type": "string", "maxLength": 256} + } + }, "main": { "type": "record", "key": "tid", @@ -15,6 +24,7 @@ "body": {"type": "string", "maxLength": 100000}, "origin": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, "closed": {"type": "boolean"}, + "source": {"type": "ref", "ref": "#source"}, "createdAt": {"type": "string", "format": "datetime"} } } diff --git a/packages/sidecar/src/cli.ts b/packages/sidecar/src/cli.ts index 91effac..d04e63f 100644 --- a/packages/sidecar/src/cli.ts +++ b/packages/sidecar/src/cli.ts @@ -120,6 +120,10 @@ export const help = `radial — human CLI for Radial records repo, and blessings already written stay blessed. radial goal create --project REF --title TITLE [--body TEXT | --body-file PATH] [--origin AT-URI#CID] + [--source-kind tangled-issue --source-uri AT-URI [--source-cid CID]] + where the text of an imported goal came from. The goal carries the pointer + so the space can tell which issues it has taken up; the text is what its + author signed, and no turn bundle carries the pointer or reads the issue. radial goal close --goal REF [--disposition completed|dropped|superseded|parked] (any active member; another word warns) radial goal reopen --goal REF (any active member) diff --git a/packages/sidecar/src/commands.ts b/packages/sidecar/src/commands.ts index 7594b46..2748040 100644 --- a/packages/sidecar/src/commands.ts +++ b/packages/sidecar/src/commands.ts @@ -1,10 +1,13 @@ import { COLLECTIONS, + GOAL_SOURCE_TANGLED_ISSUE, + TANGLED_ISSUE_COLLECTION, TURN_LIMITS, joinRkey, normalizeArtifactTitle, spaceNonce, type Collection, + type GoalSource, type RecordByCollection, type StrongRef, } from '@radial/core' @@ -526,6 +529,40 @@ function rkeyOf(uri: string): string { return match[1] as string } +/** + * `--source-kind` / `--source-uri` / `--source-cid` on `goal create`: where the text of an imported + * goal was read from, as the person writing the goal asserts it. + * + * It is an assertion and not a proof, deliberately. The title and body are what a human read on + * screen, edited if they wanted to, and signed — so re-fetching the issue here and copying its bytes + * (what `comment bless` does, and for a reason: a blessing quotes somebody VERBATIM) would take the + * editing away without buying anything. Nothing mechanical trusts this field: it hides a row in the + * candidate list so the same issue is not imported twice, and no turn bundle carries it at all. + * + * The pair is all-or-nothing because half of it is not a pointer, and the kind is checked against the + * one this build mints — `--source-kind tangled-isue` would otherwise write a goal that keeps + * offering itself for import forever. + */ +function goalSource(args: string[]): GoalSource | undefined { + const kind = optional(args, '--source-kind') + const uri = optional(args, '--source-uri') + const cid = optional(args, '--source-cid') + if (kind === undefined && uri === undefined) { + if (cid !== undefined) throw new Error('--source-cid needs --source-kind and --source-uri') + return undefined + } + if (kind === undefined || uri === undefined) { + throw new Error('Provide both --source-kind and --source-uri, or neither') + } + if (kind !== GOAL_SOURCE_TANGLED_ISSUE) { + throw new Error(`--source-kind must be ${GOAL_SOURCE_TANGLED_ISSUE} (got ${kind})`) + } + if (!uri.includes(`/${TANGLED_ISSUE_COLLECTION}/`)) { + throw new Error(`--source-uri must name a ${TANGLED_ISSUE_COLLECTION} record (got ${uri})`) + } + return { kind, uri, ...(cid === undefined ? {} : { cid }) } +} + export async function runCli(args: string[], rawDeps: CliDependencies): Promise { const deps = guardSpaceMode(rawDeps) const now = deps.now?.() ?? new Date().toISOString() @@ -807,6 +844,7 @@ export async function runCli(args: string[], rawDeps: CliDependencies): Promise< throw new Error('Goal --origin requires foreign record resolution, but it is unavailable') } const origin = originLocator ? await deps.foreignResolver!.resolve(originLocator) : undefined + const source = goalSource(args) const goal = await deps.writer.create(COLLECTIONS.goal, { $type: COLLECTIONS.goal, space: projectValue.space, @@ -814,6 +852,7 @@ export async function runCli(args: string[], rawDeps: CliDependencies): Promise< title: required(args, '--title'), body: await optionalInputText(args, deps, 'body'), ...(origin ? { origin } : {}), + ...(source ? { source } : {}), createdAt: now, }) return { primary: goal, refs: { goal } } diff --git a/packages/sidecar/test/goal-source.test.mjs b/packages/sidecar/test/goal-source.test.mjs new file mode 100644 index 0000000..79aa10d --- /dev/null +++ b/packages/sidecar/test/goal-source.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + CredentialClient, + FetchRepoTransport, + StrongRefResolver, + createSession, +} from '../../atproto/dist/index.js' +import { COLLECTIONS, GOAL_SOURCE_TANGLED_ISSUE } from '../../core/dist/index.js' +import { runCli } from '../dist/index.js' +import { LocalPds } from '../../atproto/test/local-pds.mjs' + +// Importing a tangled issue as a goal, from the write side. +// +// The shape being proved is the opposite of `comment bless`, and deliberately so. A blessing quotes a +// guest VERBATIM, so the command fetches the record and copies its bytes. An import is a goal a member +// wrote — read on screen, editable before it is signed — so the command takes the text as given and +// records only WHERE it came from. The pointer is checked for shape, because a malformed one would +// leave the candidate list offering the same issue forever, and it is never followed. + +const MEMBER = 'did:plc:member' +const ISSUE = 'at://did:plc:stranger/sh.tangled.repo.issue/3mrxi5lzk2v22' + +async function harness() { + const pds = new LocalPds(MEMBER) + const fetcher = (input, init) => pds.fetch(input, init) + const transport = new FetchRepoTransport(async () => pds.service, fetcher) + const member = { + writer: new CredentialClient( + await createSession(pds.service, pds.handle, 'password', fetcher), + fetcher, + ), + resolver: new StrongRefResolver(transport), + now: () => '2026-07-31T00:00:00Z', + readText: async () => { + throw new Error('unexpected file read') + }, + } + const space = await runCli(['space', 'create', '--name', 'Radial'], member) + const project = await runCli( + ['project', 'create', '--space', space.primary.uri, '--name', 'radial', '--git-url', 'https://tangled.org/disnetdev.com/radial'], + member, + ) + return { pds, member, project } +} + +const importArgs = (project, extra) => [ + 'goal', + 'create', + '--project', + project.primary.uri, + '--title', + 'repo operations: archive repository', + '--body', + 'It would be nice to freeze a repository without deleting it.', + ...extra, +] + +describe('goal create --source-*', () => { + it('records the pointer beside the text the member signed', async () => { + const { pds, member, project } = await harness() + const goal = await runCli( + importArgs(project, [ + '--source-kind', + GOAL_SOURCE_TANGLED_ISSUE, + '--source-uri', + ISSUE, + '--source-cid', + 'bafyreigs3nwbiql34wt6zn7mfmt257tiya3m6chxj3wvjha24gs2bp7ahe', + ]), + member, + ) + const written = pds.records.get(goal.primary.uri).value + assert.equal(written.$type, COLLECTIONS.goal) + assert.deepEqual(written.source, { + kind: GOAL_SOURCE_TANGLED_ISSUE, + uri: ISSUE, + cid: 'bafyreigs3nwbiql34wt6zn7mfmt257tiya3m6chxj3wvjha24gs2bp7ahe', + }) + // The body is what was passed, not what the issue says: an import that re-fetched would have + // thrown away every edit the reader made between reading it and pressing Create. + assert.equal(written.body, 'It would be nice to freeze a repository without deleting it.') + }) + + it('writes no source at all when none is named', async () => { + const { pds, member, project } = await harness() + const goal = await runCli(importArgs(project, []), member) + assert.equal('source' in pds.records.get(goal.primary.uri).value, false) + }) + + it('takes the pointer without a cid — a forge whose issues are not records has none', async () => { + const { pds, member, project } = await harness() + const goal = await runCli( + importArgs(project, ['--source-kind', GOAL_SOURCE_TANGLED_ISSUE, '--source-uri', ISSUE]), + member, + ) + assert.deepEqual(pds.records.get(goal.primary.uri).value.source, { + kind: GOAL_SOURCE_TANGLED_ISSUE, + uri: ISSUE, + }) + }) + + it('refuses half a pointer, and a pointer at the wrong collection', async () => { + const { member, project } = await harness() + await assert.rejects( + runCli(importArgs(project, ['--source-uri', ISSUE]), member), + /Provide both --source-kind and --source-uri/, + ) + await assert.rejects( + runCli(importArgs(project, ['--source-kind', GOAL_SOURCE_TANGLED_ISSUE]), member), + /Provide both --source-kind and --source-uri/, + ) + await assert.rejects( + runCli(importArgs(project, ['--source-cid', 'bafy']), member), + /--source-cid needs --source-kind and --source-uri/, + ) + await assert.rejects( + runCli( + importArgs(project, [ + '--source-kind', + GOAL_SOURCE_TANGLED_ISSUE, + '--source-uri', + 'at://did:plc:stranger/sh.tangled.repo.pull/3mrx', + ]), + member, + ), + /must name a sh\.tangled\.repo\.issue record/, + ) + // A typo'd kind is refused rather than written: the fold would keep it, and the candidate list + // matches on the kind it mints, so the issue would go on offering itself for import. + await assert.rejects( + runCli(importArgs(project, ['--source-kind', 'tangled-isue', '--source-uri', ISSUE]), member), + /--source-kind must be tangled-issue/, + ) + }) +}) diff --git a/packages/ui/README.md b/packages/ui/README.md index c00a628..b02839c 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -40,6 +40,7 @@ if a `node:*` import creeps back onto that path. | `src/lib/guests.ts` | Comments from people who are not members: the Constellation backlink query, the re-validation that makes the index a hint rather than an authority, and the rows the Community section draws. The only module that reads a non-member's repo, and nothing it returns enters the fold. | | `src/lib/userinput.ts` | The Userinput section: defensive parsers for a foreign feedback board, the trust fold over its grants and statuses, and the editable import draft. Authority is honoured only against the version a strongref pins; presentation resolves by URI, and latest-wins is decided by `core`'s comparators rather than the reader's locale. `discoverUserinput` reads only the board — the space's own goals are joined onto the result by `feedbackRows` at the point of drawing, so the network fan-out is a function of the board's address and not of the sync tick. Browser-only, and a member pressing Create is the only way any of it reaches the fold. | | `src/routes/p/[project]/userinput/` | The section itself. A piece of feedback is drawn with the unit row's own furniture — disc, tail, title, drawer — because it is answering the same question every other row answers; the body inside is the stranger's, so it is quoted in `.brief`, verbatim, through no markdown pass at all. The board's address is taken as either its page on userinput.app or the `at://` URI (`feedbackSourceUri` in `@radial/sidecar`, one parser for the form and the CLI). | +| `src/lib/issues.ts` | Issues on a project's tangled repository, offered as goals: the repository's own DID, the appview's issue list, the re-read from each filer's own repo that makes the index a hint, and which issues the space has already taken up. Another module reading outside the space's members, and nothing it returns enters the fold either. | | `src/lib/diagnostics.ts` | `index.ignored` and `index.edits`, grouped for display. | | `src/lib/build.ts` | Which copy of the app this tab is running — the constants `scripts/build-stamp.mjs` reads at build time and `vite.config.ts` injects, plus the origin the bundle was built for. Pure, and every input is an argument, so the one runtime value (where the tab is actually served from) is passed in. See *Build info* below. | | `src/lib/keys.ts`, `focus.ts` | The keyboard map, and focus restoration. | @@ -143,6 +144,38 @@ Signed-in non-members get a composer of their own. It writes an ordinary `messag — the same record a member's message is, through the same `runCli` path — which was always possible; what the toggle decides is whether the app asks. +### Issues on the forge, as candidate goals + +A project whose remote is on tangled has an **Issues on tangled** section at the foot of its page: +the repository's open issues, offered as goals somebody could write. It is the same kind of surface +as Community — a stranger's words this tab went and fetched — so it takes the same rail, the same +muting and the same "not a member" badge, and its module (`issues.ts`) follows the same three rules +`guests.ts` does. + +**Nothing there is in the space, and no turn reads any of it.** Ingestion polls member repos for +`com.disnetdev.radial.*`, so an `sh.tangled.repo.issue` is in no `RecordStore`, no `materialize()` +and no bundle — with no code at all. What crosses is a `goal`: importing does not write anything, it +opens the ordinary goal composer with the issue's title and body in it, saying whose words they are, +and the reader creates the goal. So what an agent reads is a record a member wrote, read first and +edited if they wanted to. That is the whole prompt-injection answer, and it is why there is no +one-press import: an issue tracker is open to anyone with an atproto account. + +The goal carries `source` — `{kind: 'tangled-issue', uri, cid}`, the version that was read — and that +is the only thing carried besides the text. It is what takes the issue out of the candidate list for +everybody in the space rather than just for whoever imported it, and it is deliberately **not** in +any turn bundle (`core/test/bundle.test.mjs` asserts that): an agent handed the at-uri could fetch +the live issue and read comments and edits nobody reviewed. + +The section does not look until it is asked to, and remembers the answer per browser profile. Tangled's +appview (`api.tangled.org`, the same Bobbin the daemon reads pull state from) has no SLA and is +somebody else's service, and a page that queried it on sight would also be telling tangled which +repositories this browser reads. When it does look: the repository's own DID is resolved the way the +daemon's adapter resolves it — the owner's handle to a DID, then the `sh.tangled.repo` record in the +owner's repo, whose `repoDid` is a *different* DID from the owner's — the appview lists the issues, +and every candidate is then re-read from the repo of whoever filed it and must still say it belongs +to this repository. The appview says where; the record says what, and when they disagree the appview +loses. An issue that cannot be re-read is counted and not shown. + ### Writing markdown, and pictures in it Every field that holds **markdown** is `MarkdownEditor.svelte`: goal bodies, thread messages and diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 6ddb329..fd4a520 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -739,11 +739,13 @@ a.btn { text-decoration: none; color: var(--ink); display: inline-block; } .msg .pending .dot { width: 13px; height: 13px; flex: none; } .msg .pending b { color: var(--ink); font-weight: 600; } -/* ─── community (comments from outside the space) ────────────────────────── */ +/* ─── community, and candidate goals (words from outside the space) ───────── */ /* Apart from the thread on purpose. Everything above is a record this space's own fold admitted; everything here is text a stranger wrote that the tab went and fetched. A guest row is inset behind its own rail and reads a shade quieter, so the difference between "somebody here said - this" and "a stranger said this" is visible before any badge is read. */ + this" and "a stranger said this" is visible before any badge is read. + Issues on the project's forge (`ProjectIssues.svelte`) are the same kind of thing arriving by a + different route, so they get the same rail rather than a second visual language for it. */ .community { margin-left: 2px; padding-left: 14px; border-left: 2px solid var(--line-soft); } /* Profile resolution gives a guest the same disc-and-prose geometry as a thread message. Keeping the body in column two makes it begin under the author's name, never under their avatar. */ @@ -753,6 +755,14 @@ a.btn { text-decoration: none; color: var(--ink); display: inline-block; } /* Blessed: this one is in every turn bundle on the goal, which is worth the accent rail. */ .community .guest.blessed { border-left: 2px solid var(--accent); margin-left: -16px; padding-left: 14px; } .community .guest .hint { font-size: 12.5px; color: var(--warn); } +/* Candidate goals: issues on the project's forge, which nothing in this space has taken up yet. The + rules are the guest row's, spelled out rather than shared with the selector above, because the two + are alike by decision and not by construction — a candidate never gets the accent rail, since + importing one produces a goal rather than putting the issue itself in front of a turn. */ +.candidates { margin-left: 2px; padding-left: 14px; border-left: 2px solid var(--line-soft); } +.candidates .guest { color: var(--ink-2); } +.candidates .guest .mb { grid-column: 2; } +.candidates .guest .msg-acts { flex-wrap: wrap; align-items: baseline; } /* ─── writing ────────────────────────────────────────────────────────────── */ /* Every field a human types a record into: the goal composer's two, the thread box, the request diff --git a/packages/ui/src/lib/components/NewGoal.svelte b/packages/ui/src/lib/components/NewGoal.svelte index fed6f7b..52011ce 100644 --- a/packages/ui/src/lib/components/NewGoal.svelte +++ b/packages/ui/src/lib/components/NewGoal.svelte @@ -3,6 +3,7 @@ import { participates } from '$lib/admin.js' import { account } from '$lib/auth.svelte.js' import { closeDraft, holdDraft } from '$lib/compose.svelte.js' + import { shortDid } from '$lib/directory.js' import { defaultGoalProject, goalProjects, @@ -10,6 +11,8 @@ newGoalArgs, rememberGoalProject, } from '$lib/goal.js' + import { importWarning, type GoalSeed } from '$lib/issues.js' + import { identify } from '$lib/session.svelte.js' import { goalUriHref, projectByUri, spaceHref, type Space } from '$lib/space.js' import { ui } from '$lib/ui.svelte.js' import { write } from '$lib/write.js' @@ -29,8 +32,16 @@ space: Space /** The project the ⊕ was pressed on, when the view it was pressed on had one. */ here?: string | undefined + /** + * Words this card opens with, from an issue on the project's forge (`issues.ts`). The import + * lands HERE rather than writing a goal straight from the candidate list, and that is the whole + * safety argument for the feature: a goal is what a member wrote and signed, so the text a + * stranger filed is in an editable field in front of them, with the sentence that says what + * creating it means, before anything is written. + */ + seed?: GoalSeed | undefined } - const { space, here }: Props = $props() + const { space, here, seed }: Props = $props() const member = $derived( account.status === 'signed-in' && participates(space.directory, account.did), @@ -50,8 +61,10 @@ const asks = preselected === '' && eligible.length > 1 let project = $state(preselected) - let title = $state('') - let body = $state('') + // svelte-ignore state_referenced_locally + let title = $state(seed?.title ?? '') + // svelte-ignore state_referenced_locally + let body = $state(seed?.body ?? '') let busy = $state(false) /** An image is still uploading, so the body would name a record that has not landed yet. */ let attaching = $state(false) @@ -61,6 +74,24 @@ const named = $derived(project ? projectByUri(space.index, project) : undefined) + // Whose words these are, resolved the way every other name in the app is (`identity.ts`: a handle + // is shown only when it round-trips back to the DID that claimed it). It starts as the short DID + // rather than as nothing, because the warning has to name somebody from the moment it is on screen. + // svelte-ignore state_referenced_locally + let filer = $state(seed ? shortDid(seed.did) : '') + $effect(() => { + if (!seed) return undefined + let current = true + void identify(seed.did) + .then((actor) => { + if (current) filer = actor.name + }) + .catch(() => undefined) + return () => { + current = false + } + }) + // The caret goes to the first thing the card actually needs, which is Title everywhere the project // is already known — the ⊕ on a project, the sole-project space, the profile that has written one // before. It is only the cold profile in a space with several projects that has to answer the @@ -98,7 +129,7 @@ busy = true error = '' try { - const result = await write(newGoalArgs({ project, title, body })) + const result = await write(newGoalArgs({ project, title, body, source: seed?.source })) // Remembered only now: a project chosen in a card that was cancelled is not a decision, and // the next ⊕ pressed on a smart list starts from where the reader last actually wrote. rememberGoalProject(project) @@ -125,7 +156,7 @@