Skip to content

Commit 4b4165b

Browse files
Merge pull request #47 from engineeredsoftware/v31/gate-4-connects-provider-readiness
V31 Gate 4: Connects Provider Readiness
2 parents 019861e + 32ad60b commit 4b4165b

24 files changed

Lines changed: 974 additions & 14 deletions

.github/workflows/bitcode-gate-quality.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ jobs:
9898
node scripts/check-v31-gate1-spec-roadmap-opening.mjs --skip-branch-check
9999
node scripts/check-v31-gate2-auxillaries-package-route-contracts.mjs --skip-branch-check
100100
node scripts/check-v31-gate3-profile-account-state.mjs --skip-branch-check
101+
node scripts/check-v31-gate4-connects-provider-readiness.mjs --skip-branch-check
101102
else
102103
echo "Unexpected BITCODE_SPEC.txt pointer: $POINTER" >&2
103104
exit 1
@@ -138,6 +139,9 @@ jobs:
138139
run: |
139140
pnpm --dir uapi exec jest --runTestsByPath \
140141
tests/userDataRoute.test.ts \
142+
tests/api/auxillariesGithubConnectionRoute.test.ts \
143+
tests/api/vcsRoutes.test.ts \
144+
tests/auxillariesExternalsPane.test.tsx \
141145
tests/profileStep.test.tsx \
142146
tests/api/readReviewRoute.test.ts \
143147
tests/api/pipelineHarnessRoute.test.ts \

BITCODE_SPEC_V31.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ Gate 4 Connects precision:
243243
- Provider readiness records name provider id, installation/account state, token presence class, scopes class, last readback status, blocker, and repair action.
244244
- Secrets and raw provider tokens are never stored in telemetry, UI metadata, or proof hooks.
245245
- Recovery attempts produce before/after readiness roots and never silently downgrade provider policy.
246+
- `AuxillariesConnectionReadiness` is the canonical provider-readiness object for Connects and VCS routes. It carries `providerId`, `providerName`, `credentialPosture`, `tokenPresenceClass`, `scopesClass`, `lastReadbackStatus`, `blocker`, `repairAction`, source-safe metadata, and `providerReadinessRoot`.
247+
- Provider connection routes may store or validate private provider credentials at the route boundary, but responses, telemetry, proof hooks, and UI metadata can only expose classified token/scope/readback posture and source-safe account labels.
248+
- `AuxillariesRecoveryRun` records provider recovery as before-readiness root, after-readiness root, repair action, execution id, outcome, and source-safety class; it never serializes the provider credential that made the repair possible.
246249

247250
Gate 5 Wallet/BTD precision:
248251

BITCODE_SPEC_V31_DELTA.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ Closure acceptance:
127127
- GitHub and provider connection routes emit source-safe readiness/recovery evidence;
128128
- recovery runs record before/after readiness roots and never leak tokens.
129129

130+
Gate 4 implementation centers:
131+
132+
- `AuxillariesConnectionReadiness` now names provider id/name, token presence class, scopes class, readback state, blocker, repair action, source-safe metadata, and `providerReadinessRoot`.
133+
- VCS connection and GitHub Auxillaries routes emit `providerReadiness` and source-safe `recoveryRun` evidence while keeping access tokens out of responses.
134+
- The Externals pane consumes `connectionReadiness` and `recoveryRuns` from `/api/auxillaries/data`, rendering classified provider posture and compact proof roots without local readiness rederivation.
135+
- Focused package, route, and pane tests prove classified provider readiness, before/after recovery roots, UI projection, and response redaction.
136+
130137
### Gate 5: Wallet And BTD Pane Readiness
131138

132139
Gate 5 connects Wallet and BTD panes to V30 wallet/BTD primitives.

BITCODE_SPEC_V31_NOTES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,10 @@ Gate 3 is closed only when Profile/account state is not just a form payload.
8787
It must be a package-owned support state that binds account identity, profile completeness issues, repair routes, wallet binding posture, model/template preference posture, notification posture, data-sharing posture, and proof roots.
8888
The Profile pane may present and link repairs, but it must consume the package contract and must not locally rederive account readiness or completeness law.
8989
Route hydration must include template preference and notification readback where available, while missing storage rows degrade into explicit repair posture rather than generic missing data.
90+
91+
## Gate 4 closure note
92+
93+
Gate 4 is closed only when Connects provider state is a source-safe readiness contract rather than a raw OAuth/VCS status blob.
94+
Provider readiness must name provider id/name, installation state, credential posture, token presence class, scopes class, last readback status, blocker, repair action, and proof root without exposing raw provider credentials.
95+
VCS and GitHub Auxillaries routes may use credentials internally to validate or repair a connection, but responses, UI metadata, telemetry, and recovery proof hooks must carry only classified posture and before/after readiness roots.
96+
The Externals pane can display compact provider proof detail, but it must consume `connectionReadiness` and `recoveryRuns` from the package-owned Auxillaries contract instead of locally deciding provider law.

BITCODE_SPEC_V31_PARITY_MATRIX.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ No `_legacy/` source is active source truth.
113113

114114
| Requirement | Source evidence | Current V31 judgment |
115115
| --- | --- | --- |
116-
| Provider readiness is source-safe | provider connection contracts and tests | pending |
117-
| Recovery runs record before/after readiness roots | recovery route and execution tests | pending |
118-
| Tokens and credentials never enter telemetry/UI metadata | source-safety tests and scans | pending |
116+
| Provider readiness is source-safe | `AuxillariesConnectionReadiness` in `packages/api/src/routes/auxillaries-contract.ts` carries provider id/name, token presence class, scopes class, last readback, blocker, repair action, source-safe metadata, and `providerReadinessRoot` | drafted |
117+
| Recovery runs record before/after readiness roots | `uapi/app/api/vcs/[provider]/connection/route.ts`, `uapi/app/api/auxillaries/connections/github/route.ts`, and `uapi/tests/api/auxillariesGithubConnectionRoute.test.ts` emit and test before/after readiness roots | drafted |
118+
| Tokens and credentials never enter telemetry/UI metadata | `packages/api/src/routes/__tests__/auxillaries-contract.test.ts`, `uapi/tests/api/vcsRoutes.test.ts`, `uapi/tests/api/auxillariesGithubConnectionRoute.test.ts`, and `uapi/tests/auxillariesExternalsPane.test.tsx` cover response/UI redaction and source-safe projection | drafted |
119119

120120
## Gate 5 Parity
121121

SPECIFICATIONS_ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- Current active canonical pointer: `BITCODE_SPEC.txt` -> `V30`
66
- Current active canon: `BITCODE_SPEC_V30.md`
77
- Current draft target: `BITCODE_SPEC_V31.md`
8-
- Current working gate: V31 Gate 3 Profile and account state, which deepens the package-owned Auxillaries Profile contract with account identity, wallet binding posture, model/template preferences, notifications, data-sharing posture, explicit blockers, and repair routes over the V30 Protocol/BTD substrate.
8+
- Current working gate: V31 Gate 4 Connects provider readiness and recovery, which deepens source-safe provider connection posture with provider id/name, installation state, token presence class, scopes class, last readback status, blocker, repair action, readiness roots, and before/after recovery evidence over the V30 Protocol/BTD substrate.
99
- Purpose: concise running index of Bitcode/ENGI specification history, current work, and planned work.
1010

1111
This roadmap is not an active system specification.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"check:v31-gate1": "node scripts/check-v31-gate1-spec-roadmap-opening.mjs",
6868
"check:v31-gate2": "node scripts/check-v31-gate2-auxillaries-package-route-contracts.mjs",
6969
"check:v31-gate3": "node scripts/check-v31-gate3-profile-account-state.mjs",
70+
"check:v31-gate4": "node scripts/check-v31-gate4-connects-provider-readiness.mjs",
7071
"check:spec-quality": "node scripts/run-bitcode-spec-quality.mjs --mode basic",
7172
"check:spec-quality:title": "node scripts/run-bitcode-spec-quality.mjs --mode strict-from-title",
7273
"check:spec-quality:v24": "node scripts/run-bitcode-spec-quality.mjs --mode strict-version --version V24",

packages/api/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ proof roots. Profile UI and routes can display or persist explicit fields, but
3434
they should not recalculate readiness locally; missing profile/account facts
3535
must degrade into named blockers or repairable issues.
3636

37+
Connects provider readiness is also package-owned. `AuxillariesConnectionReadiness`
38+
classifies provider id/name, installation state, credential posture, token
39+
presence, scopes, last readback, blocker, repair action, source-safe metadata,
40+
and a readiness root. Routes may validate credentials privately, but API
41+
responses, UI metadata, telemetry, and recovery proof hooks must only serialize
42+
classified readiness and `AuxillariesRecoveryRun` before/after roots.
43+
3744
In V26 fourth-gate this package is where merged-world Bitcode becomes concrete:
3845
- `/conversations` continuity
3946
- `/executions` compatibility and pipeline-run APIs

packages/api/src/routes/__tests__/auxillaries-contract.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ describe('Auxillaries package route contracts', () => {
5454
valid: true,
5555
provider: 'github',
5656
username: 'bitcode',
57+
scopes: ['repo', 'contents:write'],
58+
tokenPresenceClass: 'present_source_safe',
59+
lastReadbackStatus: 'succeeded',
60+
lastReadbackAt: '2026-05-21T02:00:00.000Z',
5761
metadata: {
5862
token: 'provider-token',
5963
},
@@ -130,10 +134,17 @@ describe('Auxillaries package route contracts', () => {
130134
enabledRepositoryCount: 1,
131135
});
132136
expect(payload.connectionReadiness[0]).toMatchObject({
137+
providerId: 'github',
138+
providerName: 'GitHub',
133139
provider: 'github',
134140
connected: true,
135141
valid: true,
136142
credentialPosture: 'present_source_safe',
143+
tokenPresenceClass: 'present_source_safe',
144+
scopesClass: 'repo_read_write',
145+
lastReadbackStatus: 'succeeded',
146+
blocker: null,
147+
repairAction: 'none',
137148
});
138149
expect(payload.walletBtdPaneState.signerPosture).toMatchObject({
139150
ready: true,
@@ -152,6 +163,7 @@ describe('Auxillaries package route contracts', () => {
152163
expect(JSON.stringify(payload)).not.toContain('wallet-secret');
153164
expect(JSON.stringify(payload)).not.toContain('repository source');
154165
expect(JSON.stringify(payload)).not.toContain('private prompt body');
166+
expect(JSON.stringify(payload.connectionReadiness[0].metadata)).not.toContain('provider-token');
155167
expect(assertAuxillariesJsonSafe(payload)).toBeUndefined();
156168
});
157169

@@ -175,6 +187,12 @@ describe('Auxillaries package route contracts', () => {
175187
const blockerIds = payload.readinessDiagnostics.map((diagnostic) => diagnostic.blockerId);
176188
expect(payload.profileState.accountReadiness).toBe('blocked');
177189
expect(payload.connectionReadiness[0].requiredRepairAction).toBe('connect_provider');
190+
expect(payload.connectionReadiness[0]).toMatchObject({
191+
tokenPresenceClass: 'missing',
192+
scopesClass: 'missing',
193+
lastReadbackStatus: 'not_attempted',
194+
blocker: 'connects.github.connect_provider',
195+
});
178196
expect(payload.walletBtdPaneState.signerPosture.requiredAction).toBe('connect_wallet');
179197
expect(blockerIds).toEqual(expect.arrayContaining([
180198
'profile.missing',
@@ -233,6 +251,8 @@ describe('Auxillaries package route contracts', () => {
233251

234252
expect(parseAuxillariesContractSnapshot(snapshot).recoveryRuns[0].recoveryRoot)
235253
.toMatch(/^[0-9a-f]{64}$/);
254+
expect(JSON.stringify(snapshot.recoveryRuns)).not.toContain('access_token');
255+
expect(JSON.stringify(snapshot.recoveryRuns)).not.toContain('oauth_token');
236256
expect(validateAuxillariesContractSnapshot({ kind: 'wrong' })).toMatchObject({
237257
valid: false,
238258
});

packages/api/src/routes/auxillaries-contract.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ export type AuxillariesRetryPolicy = 'manual_retry' | 'after_repair' | 'not_retr
1717
export type AuxillariesProfileRepairSeverity = 'blocking' | 'recoverable';
1818
export type AuxillariesNotificationState = 'ready' | 'attention_needed' | 'contact_missing' | 'unknown';
1919
export type AuxillariesDataSharingState = 'configured' | 'limited' | 'not_configured' | 'unknown';
20+
export type AuxillariesProviderTokenPresenceClass = 'present_source_safe' | 'missing' | 'invalid' | 'unknown';
21+
export type AuxillariesProviderScopesClass =
22+
| 'repo_read_write'
23+
| 'repo_read_only'
24+
| 'metadata_only'
25+
| 'missing'
26+
| 'unknown';
27+
export type AuxillariesProviderReadbackStatus = 'succeeded' | 'failed' | 'not_attempted' | 'unknown';
2028

2129
const CANONICAL_AUXILLARY_PANES = new Set<string>(AUXILLARY_FLOW_STEPS);
2230
const AUXILLARY_PANE_ALIASES: Record<string, ConcreteAuxillaryPane> = {
@@ -174,6 +182,8 @@ export interface AuxillariesProfileState {
174182

175183
export interface AuxillariesConnectionReadiness {
176184
kind: 'AuxillariesConnectionReadiness';
185+
providerId: string;
186+
providerName: string;
177187
provider: string;
178188
connected: boolean;
179189
valid: boolean;
@@ -184,11 +194,21 @@ export interface AuxillariesConnectionReadiness {
184194
| 'missing'
185195
| 'invalid'
186196
| 'unknown';
197+
tokenPresenceClass: AuxillariesProviderTokenPresenceClass;
198+
scopesClass: AuxillariesProviderScopesClass;
199+
lastReadbackStatus: AuxillariesProviderReadbackStatus;
200+
lastReadbackAt: string | null;
201+
blocker: string | null;
187202
requiredRepairAction:
188203
| 'none'
189204
| 'connect_provider'
190205
| 'reauthorize_provider'
191206
| 'repair_provider_inventory';
207+
repairAction:
208+
| 'none'
209+
| 'connect_provider'
210+
| 'reauthorize_provider'
211+
| 'repair_provider_inventory';
192212
sourceSafetyClass: AuxillariesSourceSafetyClass;
193213
metadata: Record<string, unknown>;
194214
providerReadinessRoot: string;
@@ -894,9 +914,18 @@ export function buildAuxillariesConnectionReadiness(input: {
894914
}): AuxillariesConnectionReadiness {
895915
const connection = asRecord(toAuxillariesJsonSafe(input.connection ?? null));
896916
const status = asRecord(toAuxillariesJsonSafe(input.connectionStatus ?? null));
917+
const statusMetadata = asRecord(status?.metadata);
918+
const connectionMetadata = asRecord(connection?.metadata);
897919
const connected = readBoolean(status?.connected) ?? Boolean(connection);
898920
const valid = readBoolean(status?.valid) ?? connected;
899921
const provider = readString(status?.provider) ?? readString(connection?.provider) ?? input.provider;
922+
const providerId = readString(status?.providerId) ??
923+
readString(status?.provider_id) ??
924+
readString(connection?.provider_id) ??
925+
provider;
926+
const providerName = readString(status?.providerName) ??
927+
readString(status?.provider_name) ??
928+
readProviderName(provider);
900929
const accountLabel =
901930
readString(status?.username) ??
902931
readString(connection?.login) ??
@@ -916,20 +945,77 @@ export function buildAuxillariesConnectionReadiness(input: {
916945
: repositories.length === 0
917946
? 'repair_provider_inventory'
918947
: 'none';
948+
const tokenPresenceClass = readProviderTokenPresenceClass(
949+
readString(status?.tokenPresenceClass) ??
950+
readString(status?.token_presence_class) ??
951+
readString(statusMetadata?.tokenPresenceClass) ??
952+
readString(statusMetadata?.token_presence_class),
953+
) ?? (!connected
954+
? 'missing'
955+
: !valid
956+
? 'invalid'
957+
: hasProviderTokenEvidence(status) || hasProviderTokenEvidence(connection)
958+
? 'present_source_safe'
959+
: 'unknown');
960+
const scopes = [
961+
...readStringList(status?.scopes),
962+
...readStringList(status?.scope),
963+
...readStringList(statusMetadata?.scopes),
964+
...readStringList(statusMetadata?.scope),
965+
...readStringList(connection?.scopes),
966+
...readStringList(connection?.scope),
967+
...readStringList(connectionMetadata?.scopes),
968+
...readStringList(connectionMetadata?.scope),
969+
];
970+
const scopesClass = readProviderScopesClass(
971+
readString(status?.scopesClass) ??
972+
readString(status?.scopes_class) ??
973+
readString(statusMetadata?.scopesClass) ??
974+
readString(statusMetadata?.scopes_class),
975+
) ?? deriveProviderScopesClass({ connected, scopes });
976+
const lastReadbackStatus = readProviderReadbackStatus(
977+
readString(status?.lastReadbackStatus) ??
978+
readString(status?.last_readback_status) ??
979+
readString(status?.readbackStatus) ??
980+
readString(status?.readback_status) ??
981+
readString(statusMetadata?.lastReadbackStatus) ??
982+
readString(statusMetadata?.last_readback_status) ??
983+
readString(statusMetadata?.readbackStatus) ??
984+
readString(statusMetadata?.readback_status),
985+
) ?? (!connected ? 'not_attempted' : valid ? 'succeeded' : 'failed');
986+
const lastReadbackAt = readString(status?.lastReadbackAt) ??
987+
readString(status?.last_readback_at) ??
988+
readString(statusMetadata?.lastReadbackAt) ??
989+
readString(statusMetadata?.last_readback_at) ??
990+
null;
991+
const blocker = readString(status?.blocker) ??
992+
readString(statusMetadata?.blocker) ??
993+
(requiredRepairAction === 'none' ? null : `connects.${provider}.${requiredRepairAction}`);
994+
const repairAction = requiredRepairAction;
919995
const withoutRoot = {
920996
kind: 'AuxillariesConnectionReadiness' as const,
997+
providerId,
998+
providerName,
921999
provider,
9221000
connected,
9231001
valid,
9241002
accountLabel,
9251003
installationState: connected ? 'installed' as const : 'missing' as const,
9261004
credentialPosture,
1005+
tokenPresenceClass,
1006+
scopesClass,
1007+
lastReadbackStatus,
1008+
lastReadbackAt,
1009+
blocker,
9271010
requiredRepairAction,
1011+
repairAction,
9281012
sourceSafetyClass: 'secret_free_summary' as AuxillariesSourceSafetyClass,
9291013
metadata: {
9301014
repositories: repositories.length,
1015+
repositoryCount: repositories.length,
9311016
instanceUrl: readString(status?.instanceUrl),
9321017
expiresAt: readString(status?.expiresAt),
1018+
scopes,
9331019
},
9341020
};
9351021

@@ -1461,6 +1547,92 @@ function readDataSharingState(value: string | null): AuxillariesDataSharingState
14611547
return null;
14621548
}
14631549

1550+
function readProviderName(provider: string) {
1551+
if (provider === 'github') return 'GitHub';
1552+
if (provider === 'gitlab') return 'GitLab';
1553+
if (provider === 'bitbucket') return 'Bitbucket';
1554+
return provider;
1555+
}
1556+
1557+
function readProviderTokenPresenceClass(value: string | null): AuxillariesProviderTokenPresenceClass | null {
1558+
if (
1559+
value === 'present_source_safe' ||
1560+
value === 'missing' ||
1561+
value === 'invalid' ||
1562+
value === 'unknown'
1563+
) {
1564+
return value;
1565+
}
1566+
return null;
1567+
}
1568+
1569+
function readProviderScopesClass(value: string | null): AuxillariesProviderScopesClass | null {
1570+
if (
1571+
value === 'repo_read_write' ||
1572+
value === 'repo_read_only' ||
1573+
value === 'metadata_only' ||
1574+
value === 'missing' ||
1575+
value === 'unknown'
1576+
) {
1577+
return value;
1578+
}
1579+
return null;
1580+
}
1581+
1582+
function readProviderReadbackStatus(value: string | null): AuxillariesProviderReadbackStatus | null {
1583+
if (
1584+
value === 'succeeded' ||
1585+
value === 'failed' ||
1586+
value === 'not_attempted' ||
1587+
value === 'unknown'
1588+
) {
1589+
return value;
1590+
}
1591+
return null;
1592+
}
1593+
1594+
function readStringList(value: unknown): string[] {
1595+
if (Array.isArray(value)) {
1596+
return value
1597+
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
1598+
.filter(Boolean);
1599+
}
1600+
if (typeof value === 'string') {
1601+
return value
1602+
.split(/[,\s]+/u)
1603+
.map((entry) => entry.trim())
1604+
.filter(Boolean);
1605+
}
1606+
return [];
1607+
}
1608+
1609+
function hasProviderTokenEvidence(value: UnknownRecord | null) {
1610+
if (!value) return false;
1611+
return [
1612+
value.token_present,
1613+
value.tokenPresent,
1614+
value.access_token,
1615+
value.oauth_token,
1616+
value.refresh_token,
1617+
value.token,
1618+
].some((entry) => readBoolean(entry) === true || readString(entry) === REDACTED_VALUE);
1619+
}
1620+
1621+
function deriveProviderScopesClass(input: {
1622+
connected: boolean;
1623+
scopes: string[];
1624+
}): AuxillariesProviderScopesClass {
1625+
if (!input.connected) return 'missing';
1626+
if (input.scopes.length === 0) return 'unknown';
1627+
const normalized = input.scopes.map((scope) => scope.toLowerCase());
1628+
const hasRepo = normalized.some((scope) => scope.includes('repo') || scope.includes('repository'));
1629+
const hasWrite = normalized.some((scope) => /(?:write|push|contents:write|metadata:write)/u.test(scope));
1630+
const hasRead = normalized.some((scope) => /(?:read|pull|contents:read|metadata:read)/u.test(scope));
1631+
if (hasRepo && hasWrite) return 'repo_read_write';
1632+
if (hasRepo || hasRead) return 'repo_read_only';
1633+
return 'metadata_only';
1634+
}
1635+
14641636
function stableProofRoot(label: string, value: unknown) {
14651637
return createHash('sha256')
14661638
.update(`${label}:${stableStringify(toAuxillariesJsonSafe(value))}`)

0 commit comments

Comments
 (0)